mirror of
https://github.com/bitwarden/server
synced 2025-12-26 21:23:39 +00:00
Merge branch 'main' into vault/pm-25957/sharing-cipher-to-org
This commit is contained in:
@@ -123,3 +123,12 @@ csharp_style_namespace_declarations = file_scoped:warning
|
||||
# Switch expression
|
||||
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
|
||||
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
|
||||
|
||||
# CA2253: Named placeholders should nto be numeric values
|
||||
dotnet_diagnostic.CA2253.severity = suggestion
|
||||
|
||||
# CA2254: Template should be a static expression
|
||||
dotnet_diagnostic.CA2254.severity = warning
|
||||
|
||||
# CA1727: Use PascalCase for named placeholders
|
||||
dotnet_diagnostic.CA1727.severity = suggestion
|
||||
|
||||
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -96,6 +96,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
|
||||
# The PushType enum is expected to be editted by anyone without need for Platform review
|
||||
src/Core/Platform/Push/PushType.cs
|
||||
|
||||
# SDK
|
||||
util/RustSdk @bitwarden/team-sdk-sme
|
||||
|
||||
# Multiple owners - DO NOT REMOVE (BRE)
|
||||
**/packages.lock.json
|
||||
Directory.Build.props
|
||||
|
||||
6
.github/renovate.json5
vendored
6
.github/renovate.json5
vendored
@@ -2,6 +2,7 @@
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
|
||||
enabledManagers: [
|
||||
"cargo",
|
||||
"dockerfile",
|
||||
"docker-compose",
|
||||
"github-actions",
|
||||
@@ -9,6 +10,11 @@
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: "cargo minor",
|
||||
matchManagers: ["cargo"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
|
||||
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' }}
|
||||
|
||||
69
.github/workflows/build.yml
vendored
69
.github/workflows/build.yml
vendored
@@ -28,9 +28,10 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@@ -97,27 +98,28 @@ 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
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -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,16 +262,17 @@ 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
|
||||
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0
|
||||
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
@@ -297,9 +300,10 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
@@ -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,9 +427,10 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
33
.github/workflows/review-code.yml
vendored
33
.github/workflows/review-code.yml
vendored
@@ -26,14 +26,14 @@ jobs:
|
||||
id: check_changes
|
||||
run: |
|
||||
# Ensure we have the base branch
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
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)
|
||||
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
|
||||
echo "vault_team_changes=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
if [ -z "$VAULT_PATTERNS" ]; then
|
||||
echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS"
|
||||
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "vault_team_changes=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT
|
||||
echo "vault_team_changes=$vault_team_changes" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$vault_team_changes" = "true" ]; then
|
||||
echo ""
|
||||
@@ -84,16 +84,18 @@ jobs:
|
||||
|
||||
- name: Review with Claude Code
|
||||
if: steps.check_changes.outputs.vault_team_changes == 'true'
|
||||
uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7
|
||||
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
|
||||
@@ -103,7 +105,20 @@ jobs:
|
||||
|
||||
Note: The PR branch is already checked out in the current working directory.
|
||||
|
||||
Provide detailed feedback using inline comments for specific issues.
|
||||
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_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
--allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
|
||||
18
.github/workflows/test-database.yml
vendored
18
.github/workflows/test-database.yml
vendored
@@ -45,9 +45,11 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -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,9 +179,11 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -28,9 +28,19 @@ 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -215,6 +215,9 @@ bitwarden_license/src/Sso/wwwroot/assets
|
||||
**/**.swp
|
||||
.mono
|
||||
src/Core/MailTemplates/Mjml/out
|
||||
src/Core/MailTemplates/Mjml/out-hbs
|
||||
NativeMethods.g.cs
|
||||
util/RustSdk/rust/target
|
||||
|
||||
src/Admin/Admin.zip
|
||||
src/Api/Api.zip
|
||||
@@ -231,3 +234,4 @@ bitwarden_license/src/Sso/Sso.zip
|
||||
/identity.json
|
||||
/api.json
|
||||
/api.public.json
|
||||
.serena/
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -1,24 +1,29 @@
|
||||
# Bitwarden Server - Claude Code Configuration
|
||||
|
||||
## Project Context Files
|
||||
|
||||
**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines**
|
||||
|
||||
1. @README.md
|
||||
2. @CONTRIBUTING.md
|
||||
3. @.github/PULL_REQUEST_TEMPLATE.md
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files
|
||||
- **NEVER** use code regions: If complexity suggests regions, refactor for better readability
|
||||
|
||||
- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden
|
||||
|
||||
- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages
|
||||
|
||||
- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity
|
||||
|
||||
- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use
|
||||
|
||||
- **ALWAYS** prioritize cryptographic integrity and data protection
|
||||
|
||||
- **ALWAYS** add unit tests (with mocking) for any new feature development
|
||||
|
||||
## Project Context
|
||||
|
||||
- **Architecture**: Feature and team-based organization
|
||||
- **Framework**: .NET 8.0, ASP.NET Core
|
||||
- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite
|
||||
- **Testing**: xUnit, NSubstitute
|
||||
- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **Source Code**: `/src/` - Services and core infrastructure
|
||||
@@ -42,7 +47,7 @@
|
||||
- **Database update**: `pwsh dev/migrate.ps1`
|
||||
- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1`
|
||||
|
||||
## Code Review Checklist
|
||||
## Development Workflow
|
||||
|
||||
- Security impact assessed
|
||||
- xUnit tests added / updated
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.10.0</Version>
|
||||
<Version>2025.10.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
|
||||
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
|
||||
@@ -32,19 +30,4 @@
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
|
||||
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
|
||||
</Exec>
|
||||
</Target>
|
||||
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>GitHash</_Parameter1>
|
||||
<_Parameter2>$(SourceRevisionId)</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -133,6 +133,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||
EndProject
|
||||
Global
|
||||
@@ -339,6 +340,10 @@ Global
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -397,6 +402,7 @@ Global
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
|
||||
@@ -148,22 +148,30 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
}
|
||||
else if (organization.IsStripeEnabled())
|
||||
{
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
if (subscription.Customer.Discount?.Coupon != null)
|
||||
{
|
||||
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
|
||||
}
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30
|
||||
DaysUntilDue = 30,
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentSource(organization);
|
||||
|
||||
@@ -481,7 +481,6 @@ public class ProviderBillingService(
|
||||
City = billingAddress.City,
|
||||
State = billingAddress.State
|
||||
},
|
||||
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
@@ -663,6 +662,7 @@ public class ProviderBillingService(
|
||||
: CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
|
||||
OffSession = true,
|
||||
@@ -671,7 +671,6 @@ public class ProviderBillingService(
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
};
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
|
||||
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -157,6 +157,6 @@ public class Startup
|
||||
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
|
||||
|
||||
// Log startup
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,16 +156,18 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Contains("customer")))
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
|
||||
|
||||
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
@@ -368,10 +370,21 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
|
||||
private static Subscription GetSubscription(string subscriptionId) =>
|
||||
private static Subscription GetSubscription(string subscriptionId, string customerId) =>
|
||||
new()
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "coupon-id"
|
||||
}
|
||||
}
|
||||
},
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
|
||||
@@ -436,7 +436,7 @@ public class PatchGroupCommandTests
|
||||
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
|
||||
|
||||
// Assert: logging
|
||||
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default);
|
||||
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning("");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
"Namespaces": [
|
||||
{
|
||||
"Name": "sbemulatorns",
|
||||
"Queues": [
|
||||
{
|
||||
"Name": "queue.1",
|
||||
"Properties": {
|
||||
"DeadLetteringOnMessageExpiration": false,
|
||||
"DefaultMessageTimeToLive": "PT1H",
|
||||
"DuplicateDetectionHistoryTimeWindow": "PT20S",
|
||||
"ForwardDeadLetteredMessagesTo": "",
|
||||
"ForwardTo": "",
|
||||
"LockDuration": "PT1M",
|
||||
"MaxDeliveryCount": 3,
|
||||
"RequiresDuplicateDetection": false,
|
||||
"RequiresSession": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"Topics": [
|
||||
{
|
||||
"Name": "event-logging",
|
||||
@@ -37,6 +21,9 @@
|
||||
},
|
||||
{
|
||||
"Name": "events-datadog-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-teams-subscription"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -98,6 +85,20 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "integration-teams-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "teams-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "teams"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -472,6 +472,7 @@ public class OrganizationsController : Controller
|
||||
organization.UseRiskInsights = model.UseRiskInsights;
|
||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||
|
||||
//secrets
|
||||
organization.SmSeats = model.SmSeats;
|
||||
|
||||
@@ -106,6 +106,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
SmServiceAccounts = org.SmServiceAccounts;
|
||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||
|
||||
_plans = plans;
|
||||
}
|
||||
|
||||
@@ -192,6 +194,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
[Display(Name = "Use Organization Domains")]
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
|
||||
[Display(Name = "Automatic User Confirmation")]
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
/**
|
||||
* Creates a Plan[] object for use in Javascript
|
||||
* This is mapped manually below to provide some type safety in case the plan objects change
|
||||
@@ -231,6 +235,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
LegacyYear = p.LegacyYear,
|
||||
Disabled = p.Disabled,
|
||||
SupportsSecretsManager = p.SupportsSecretsManager,
|
||||
AutomaticUserConfirmation = p.AutomaticUserConfirmation,
|
||||
PasswordManager =
|
||||
new
|
||||
{
|
||||
|
||||
@@ -159,6 +159,13 @@
|
||||
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||
</div>
|
||||
}
|
||||
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseAutomaticUserConfirmation" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseAutomaticUserConfirmation"></label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<h3>Password Manager</h3>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core.Billing.Providers.Migration.Models;
|
||||
using Bit.Core.Billing.Providers.Migration.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Admin.Billing.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("migrate-providers")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class MigrateProvidersController(
|
||||
IProviderMigrator providerMigrator) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View(new MigrateProvidersRequestModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
|
||||
{
|
||||
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
|
||||
|
||||
if (providerIds.Count == 0)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
foreach (var providerId in providerIds)
|
||||
{
|
||||
await providerMigrator.Migrate(providerId);
|
||||
}
|
||||
|
||||
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
|
||||
}
|
||||
|
||||
[HttpGet("results")]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
|
||||
{
|
||||
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
|
||||
|
||||
if (providerIds.Count == 0)
|
||||
{
|
||||
return View(Array.Empty<ProviderMigrationResult>());
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
|
||||
|
||||
return View(results);
|
||||
}
|
||||
|
||||
[HttpGet("results/{providerId:guid}")]
|
||||
[RequirePermission(Permission.Tools_MigrateProviders)]
|
||||
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var result = await providerMigrator.GetResult(providerId);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
return View(result);
|
||||
}
|
||||
|
||||
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
|
||||
? text.Split(
|
||||
["\r\n", "\r", "\n"],
|
||||
StringSplitOptions.TrimEntries
|
||||
)
|
||||
.Select(id => new Guid(id))
|
||||
.ToList()
|
||||
: [];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class MigrateProvidersRequestModel
|
||||
{
|
||||
[Required]
|
||||
[Display(Name = "Provider IDs")]
|
||||
public string ProviderIds { get; set; }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
@using System.Text.Json
|
||||
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Migration Details: @Model.ProviderName</h2>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4 col-lg-3">Id</dt>
|
||||
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Result</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
|
||||
</dl>
|
||||
<h3>Client Organizations</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Result</th>
|
||||
<th>Previous State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var clientResult in Model.Clients)
|
||||
{
|
||||
<tr>
|
||||
<td>@clientResult.OrganizationId</td>
|
||||
<td>@clientResult.OrganizationName</td>
|
||||
<td>@clientResult.Result</td>
|
||||
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
|
||||
@{
|
||||
ViewData["Title"] = "Migrate Providers";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Bulk Consolidated Billing Migration Tool</h2>
|
||||
<section>
|
||||
<p>
|
||||
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
|
||||
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
|
||||
</p>
|
||||
<p class="alert alert-warning">
|
||||
Updates made through this tool are irreversible without manual intervention.
|
||||
</p>
|
||||
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
|
||||
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
|
||||
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
|
||||
174e82fc-70c3-448d-9fe7-00bad2a3ab00
|
||||
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="Run" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" asp-for="ProviderIds"></label>
|
||||
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -1,28 +0,0 @@
|
||||
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
||||
<h1>Migrate Providers</h1>
|
||||
<h2>Results</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var result in Model)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
|
||||
<td>@result.ProviderName</td>
|
||||
<td>@result.Result</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ public class HomeController : Controller
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}");
|
||||
_logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
|
||||
return new JsonResult("Unable to fetch latest version") { StatusCode = StatusCodes.Status500InternalServerError };
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ public class HomeController : Controller
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}");
|
||||
_logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
|
||||
return new JsonResult("Unable to fetch installed version") { StatusCode = StatusCodes.Status500InternalServerError };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Models;
|
||||
@@ -10,7 +9,6 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Platform.Installations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -33,7 +31,6 @@ public class ToolsController : Controller
|
||||
private readonly IInstallationRepository _installationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
@@ -46,7 +43,6 @@ public class ToolsController : Controller
|
||||
IInstallationRepository installationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPaymentService paymentService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
@@ -58,7 +54,6 @@ public class ToolsController : Controller
|
||||
_installationRepository = installationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_paymentService = paymentService;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_environment = environment;
|
||||
}
|
||||
@@ -341,138 +336,4 @@ public class ToolsController : Controller
|
||||
throw new Exception("No license to generate.");
|
||||
}
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
|
||||
public async Task<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
|
||||
{
|
||||
options = options ?? new StripeSubscriptionListOptions();
|
||||
options.Limit = 10;
|
||||
options.Expand = new List<string>() { "data.customer", "data.latest_invoice" };
|
||||
options.SelectAll = false;
|
||||
|
||||
var subscriptions = await _stripeAdapter.SubscriptionListAsync(options);
|
||||
|
||||
options.StartingAfter = subscriptions.LastOrDefault()?.Id;
|
||||
options.EndingBefore = await StripeSubscriptionsGetHasPreviousPage(subscriptions, options) ?
|
||||
subscriptions.FirstOrDefault()?.Id :
|
||||
null;
|
||||
|
||||
var isProduction = _environment.IsProduction();
|
||||
var model = new StripeSubscriptionsModel()
|
||||
{
|
||||
Items = subscriptions.Select(s => new StripeSubscriptionRowModel(s)).ToList(),
|
||||
Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data,
|
||||
TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync(),
|
||||
Filter = options
|
||||
};
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
|
||||
public async Task<IActionResult> StripeSubscriptions([FromForm] StripeSubscriptionsModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var isProduction = _environment.IsProduction();
|
||||
model.Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data;
|
||||
model.TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync();
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (model.Action == StripeSubscriptionsAction.Export || model.Action == StripeSubscriptionsAction.BulkCancel)
|
||||
{
|
||||
var subscriptions = model.Filter.SelectAll ?
|
||||
await _stripeAdapter.SubscriptionListAsync(model.Filter) :
|
||||
model.Items.Where(x => x.Selected).Select(x => x.Subscription);
|
||||
|
||||
if (model.Action == StripeSubscriptionsAction.Export)
|
||||
{
|
||||
return StripeSubscriptionsExport(subscriptions);
|
||||
}
|
||||
|
||||
if (model.Action == StripeSubscriptionsAction.BulkCancel)
|
||||
{
|
||||
await StripeSubscriptionsCancel(subscriptions);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (model.Action == StripeSubscriptionsAction.PreviousPage || model.Action == StripeSubscriptionsAction.Search)
|
||||
{
|
||||
model.Filter.StartingAfter = null;
|
||||
}
|
||||
|
||||
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
|
||||
{
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
|
||||
if (subscription.Status == "canceled")
|
||||
{
|
||||
model.Filter.StartingAfter = null;
|
||||
}
|
||||
}
|
||||
model.Filter.EndingBefore = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return RedirectToAction("StripeSubscriptions", model.Filter);
|
||||
}
|
||||
|
||||
// This requires a redundant API call to Stripe because of the way they handle pagination.
|
||||
// The StartingBefore value has to be inferred from the list we get, and isn't supplied by Stripe.
|
||||
private async Task<bool> StripeSubscriptionsGetHasPreviousPage(List<Stripe.Subscription> subscriptions, StripeSubscriptionListOptions options)
|
||||
{
|
||||
var hasPreviousPage = false;
|
||||
if (subscriptions.FirstOrDefault()?.Id != null)
|
||||
{
|
||||
var previousPageSearchOptions = new StripeSubscriptionListOptions()
|
||||
{
|
||||
EndingBefore = subscriptions.FirstOrDefault().Id,
|
||||
Limit = 1,
|
||||
Status = options.Status,
|
||||
CurrentPeriodEndDate = options.CurrentPeriodEndDate,
|
||||
CurrentPeriodEndRange = options.CurrentPeriodEndRange,
|
||||
Price = options.Price
|
||||
};
|
||||
hasPreviousPage = (await _stripeAdapter.SubscriptionListAsync(previousPageSearchOptions)).Count > 0;
|
||||
}
|
||||
return hasPreviousPage;
|
||||
}
|
||||
|
||||
private async Task StripeSubscriptionsCancel(IEnumerable<Stripe.Subscription> subscriptions)
|
||||
{
|
||||
foreach (var s in subscriptions)
|
||||
{
|
||||
await _stripeAdapter.SubscriptionCancelAsync(s.Id);
|
||||
if (s.LatestInvoice?.Status == "open")
|
||||
{
|
||||
await _stripeAdapter.InvoiceVoidInvoiceAsync(s.LatestInvoiceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private FileResult StripeSubscriptionsExport(IEnumerable<Stripe.Subscription> subscriptions)
|
||||
{
|
||||
var fieldsToExport = subscriptions.Select(s => new
|
||||
{
|
||||
StripeId = s.Id,
|
||||
CustomerEmail = s.Customer?.Email,
|
||||
SubscriptionStatus = s.Status,
|
||||
InvoiceDueDate = s.CurrentPeriodEnd,
|
||||
SubscriptionProducts = s.Items?.Data.Select(p => p.Plan.Id)
|
||||
});
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
var result = System.Text.Json.JsonSerializer.Serialize(fieldsToExport, options);
|
||||
var bytes = Encoding.UTF8.GetBytes(result);
|
||||
return File(bytes, "application/json", "StripeSubscriptionsSearch.json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ public enum Permission
|
||||
Tools_PromoteProviderServiceUser,
|
||||
Tools_GenerateLicenseFile,
|
||||
Tools_ManageTaxRates,
|
||||
Tools_ManageStripeSubscriptions,
|
||||
Tools_CreateEditTransaction,
|
||||
Tools_ProcessStripeEvents,
|
||||
Tools_MigrateProviders
|
||||
Tools_ProcessStripeEvents
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class AliveJob : BaseJob
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Execute job task: Keep alive");
|
||||
var response = await _httpClient.GetAsync(_globalSettings.BaseServiceUri.Admin);
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, " +
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, {StatusCode}",
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +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.Models.BitStripe;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
public class StripeSubscriptionRowModel
|
||||
{
|
||||
public Stripe.Subscription Subscription { get; set; }
|
||||
public bool Selected { get; set; }
|
||||
|
||||
public StripeSubscriptionRowModel() { }
|
||||
public StripeSubscriptionRowModel(Stripe.Subscription subscription)
|
||||
{
|
||||
Subscription = subscription;
|
||||
}
|
||||
}
|
||||
|
||||
public enum StripeSubscriptionsAction
|
||||
{
|
||||
Search,
|
||||
PreviousPage,
|
||||
NextPage,
|
||||
Export,
|
||||
BulkCancel
|
||||
}
|
||||
|
||||
public class StripeSubscriptionsModel : IValidatableObject
|
||||
{
|
||||
public List<StripeSubscriptionRowModel> Items { get; set; }
|
||||
public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search;
|
||||
public string Message { get; set; }
|
||||
public List<Stripe.Price> Prices { get; set; }
|
||||
public List<Stripe.TestHelpers.TestClock> TestClocks { get; set; }
|
||||
public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions();
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid")
|
||||
{
|
||||
yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Providers.Migration;
|
||||
|
||||
#if !OSS
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
@@ -92,7 +91,6 @@ public class Startup
|
||||
services.AddDistributedCache(globalSettings);
|
||||
services.AddBillingOperations();
|
||||
services.AddHttpClient();
|
||||
services.AddProviderMigration();
|
||||
|
||||
#if OSS
|
||||
services.AddOosServices();
|
||||
|
||||
@@ -52,8 +52,7 @@ public static class RolePermissionMapping
|
||||
Permission.Tools_PromoteAdmin,
|
||||
Permission.Tools_PromoteProviderServiceUser,
|
||||
Permission.Tools_GenerateLicenseFile,
|
||||
Permission.Tools_ManageTaxRates,
|
||||
Permission.Tools_ManageStripeSubscriptions
|
||||
Permission.Tools_ManageTaxRates
|
||||
}
|
||||
},
|
||||
{ "admin", new List<Permission>
|
||||
@@ -105,7 +104,6 @@ public static class RolePermissionMapping
|
||||
Permission.Tools_PromoteProviderServiceUser,
|
||||
Permission.Tools_GenerateLicenseFile,
|
||||
Permission.Tools_ManageTaxRates,
|
||||
Permission.Tools_ManageStripeSubscriptions,
|
||||
Permission.Tools_CreateEditTransaction
|
||||
}
|
||||
},
|
||||
@@ -180,10 +178,8 @@ public static class RolePermissionMapping
|
||||
Permission.Tools_ChargeBrainTreeCustomer,
|
||||
Permission.Tools_GenerateLicenseFile,
|
||||
Permission.Tools_ManageTaxRates,
|
||||
Permission.Tools_ManageStripeSubscriptions,
|
||||
Permission.Tools_CreateEditTransaction,
|
||||
Permission.Tools_ProcessStripeEvents,
|
||||
Permission.Tools_MigrateProviders
|
||||
Permission.Tools_ProcessStripeEvents
|
||||
}
|
||||
},
|
||||
{ "sales", new List<Permission>
|
||||
|
||||
@@ -13,12 +13,10 @@
|
||||
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
|
||||
var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
|
||||
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
|
||||
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
|
||||
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
|
||||
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
|
||||
|
||||
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser ||
|
||||
canGenerateLicense || canManageStripeSubscriptions;
|
||||
canGenerateLicense;
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -102,12 +100,6 @@
|
||||
<a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense">
|
||||
Generate License
|
||||
</a>
|
||||
}
|
||||
@if (canManageStripeSubscriptions)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">
|
||||
Manage Stripe Subscriptions
|
||||
</a>
|
||||
}
|
||||
@if (canProcessStripeEvents)
|
||||
{
|
||||
@@ -115,12 +107,6 @@
|
||||
Process Stripe Events
|
||||
</a>
|
||||
}
|
||||
@if (canMigrateProviders)
|
||||
{
|
||||
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
|
||||
Migrate Providers
|
||||
</a>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
@model StripeSubscriptionsModel
|
||||
@{
|
||||
ViewData["Title"] = "Stripe Subscriptions";
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function onRowSelect(selectingPage = false) {
|
||||
let checkboxes = document.getElementsByClassName('row-check');
|
||||
let checkedCheckboxCount = 0;
|
||||
let bulkActions = document.getElementById('bulkActions');
|
||||
|
||||
let selectPage = document.getElementById('selectPage');
|
||||
for(let i = 0; i < checkboxes.length; i++){
|
||||
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
|
||||
checkboxes[i].checked = true;
|
||||
checkedCheckboxCount += 1;
|
||||
} else {
|
||||
checkboxes[i].checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if(checkedCheckboxCount > 0) {
|
||||
bulkActions.classList.remove("d-none");
|
||||
} else {
|
||||
bulkActions.classList.add("d-none");
|
||||
}
|
||||
|
||||
let selectAll = document.getElementById('selectAll');
|
||||
if (checkedCheckboxCount === checkboxes.length) {
|
||||
selectPage.checked = true;
|
||||
selectAll.classList.remove("d-none");
|
||||
|
||||
let selectAllElement = document.getElementById('selectAllElement');
|
||||
selectAllElement.classList.remove('d-none');
|
||||
|
||||
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
selectedAllConfirmation.classList.add('d-none');
|
||||
} else {
|
||||
selectPage.checked = false;
|
||||
selectAll.classList.add("d-none");
|
||||
let selectAllInput = document.getElementById('selectAllInput');
|
||||
selectAllInput.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
let selectAllInput = document.getElementById('selectAllInput');
|
||||
selectAllInput.checked = true;
|
||||
|
||||
let selectAllElement = document.getElementById('selectAllElement');
|
||||
selectAllElement.classList.add('d-none');
|
||||
|
||||
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
|
||||
selectedAllConfirmation.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function exportSelectedSubscriptions() {
|
||||
let selectAll = document.getElementById('selectAll');
|
||||
let httpRequest = new XMLHttpRequest();
|
||||
httpRequest.open("POST");
|
||||
httpRequest.send();
|
||||
}
|
||||
|
||||
function cancelSelectedSubscriptions() {
|
||||
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
<h2>Manage Stripe Subscriptions</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success"></div>
|
||||
}
|
||||
<form method="post">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.Status">Status</label>
|
||||
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
|
||||
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
|
||||
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
|
||||
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-text">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
|
||||
<label class="form-check-label me-2" for="beforeRadio">Before</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
|
||||
<label class="form-check-label" for="afterRadio">After</label>
|
||||
</div>
|
||||
</div>
|
||||
@{
|
||||
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
|
||||
}
|
||||
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.Price">Price ID</label>
|
||||
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
|
||||
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{
|
||||
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 text-end">
|
||||
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
|
||||
<i class="fa fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
|
||||
<div class="text-center row d-flex justify-content-center">
|
||||
<div id="selectAll" class="d-none col-8">
|
||||
All @Model.Items.Count subscriptions on this page are selected.<br/>
|
||||
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
|
||||
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
|
||||
<i class="fa fa-check"></i> All subscriptions for this search are selected.
|
||||
</span>
|
||||
<div class="alert alert-warning mt-2" role="alert">
|
||||
Please be aware that bulk operations may take several minutes to complete.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="form-check">
|
||||
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
|
||||
</div>
|
||||
</th>
|
||||
<th>Id</th>
|
||||
<th>Customer Email</th>
|
||||
<th>Status</th>
|
||||
<th>Product Tier</th>
|
||||
<th>Current Period End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">No results to list.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@for (var i = 0; i < Model.Items.Count; i++)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@{
|
||||
var i0 = i;
|
||||
}
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
|
||||
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
|
||||
|
||||
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
|
||||
{
|
||||
var i1 = i;
|
||||
var j1 = j;
|
||||
<input
|
||||
type="hidden"
|
||||
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
|
||||
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
|
||||
}
|
||||
<div class="form-check">
|
||||
|
||||
@{
|
||||
var i2 = i;
|
||||
}
|
||||
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Id
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Customer?.Email
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.Status
|
||||
</td>
|
||||
<td>
|
||||
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
|
||||
</td>
|
||||
<td>
|
||||
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav class="d-inline-flex align-items-center">
|
||||
<ul class="pagination mb-0">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
|
||||
<li class="page-item">
|
||||
<button
|
||||
type="submit"
|
||||
class="page-link"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.PreviousPage">
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
|
||||
{
|
||||
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
|
||||
<li class="page-item">
|
||||
<button class="page-link"
|
||||
type="submit"
|
||||
name="action"
|
||||
asp-for="Action"
|
||||
value="@StripeSubscriptionsAction.NextPage">
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Next</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<span id="bulkActions" class="d-none ms-3">
|
||||
<span class="d-inline-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
|
||||
Export
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
|
||||
Bulk Cancel
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</nav>
|
||||
</form>
|
||||
@@ -32,7 +32,7 @@ public class SlackIntegrationController(
|
||||
}
|
||||
|
||||
string? callbackUrl = Url.RouteUrl(
|
||||
routeName: nameof(CreateAsync),
|
||||
routeName: "SlackIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
@@ -76,7 +76,7 @@ public class SlackIntegrationController(
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[HttpGet("integrations/slack/create", Name = nameof(CreateAsync))]
|
||||
[HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
|
||||
{
|
||||
@@ -103,7 +103,7 @@ public class SlackIntegrationController(
|
||||
|
||||
// Fetch token from Slack and store to DB
|
||||
string? callbackUrl = Url.RouteUrl(
|
||||
routeName: nameof(CreateAsync),
|
||||
routeName: "SlackIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
|
||||
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal file
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
public class TeamsIntegrationController(
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
IBot bot,
|
||||
IBotFrameworkHttpAdapter adapter,
|
||||
ITeamsService teamsService,
|
||||
TimeProvider timeProvider) : Controller
|
||||
{
|
||||
[HttpGet("{organizationId:guid}/integrations/teams/redirect")]
|
||||
public async Task<IActionResult> RedirectAsync(Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.OrganizationOwner(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var callbackUrl = Url.RouteUrl(
|
||||
routeName: "TeamsIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
);
|
||||
if (string.IsNullOrEmpty(callbackUrl))
|
||||
{
|
||||
throw new BadRequestException("Unable to build callback Url");
|
||||
}
|
||||
|
||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
||||
var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams);
|
||||
|
||||
if (integration is null)
|
||||
{
|
||||
// No teams integration exists, create Initiated version
|
||||
integration = await integrationRepository.CreateAsync(new OrganizationIntegration
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Type = IntegrationType.Teams,
|
||||
Configuration = null,
|
||||
});
|
||||
}
|
||||
else if (integration.Configuration is not null)
|
||||
{
|
||||
// A Completed (fully configured) Teams integration already exists, throw to prevent overriding
|
||||
throw new BadRequestException("There already exists a Teams integration for this organization");
|
||||
|
||||
} // An Initiated teams integration exits, re-use it and kick off a new OAuth flow
|
||||
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
|
||||
var redirectUrl = teamsService.GetRedirectUrl(
|
||||
callbackUrl: callbackUrl,
|
||||
state: state.ToString()
|
||||
);
|
||||
|
||||
if (string.IsNullOrEmpty(redirectUrl))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return Redirect(redirectUrl);
|
||||
}
|
||||
|
||||
[HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
|
||||
{
|
||||
var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider);
|
||||
if (oAuthState is null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Fetch existing Initiated record
|
||||
var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId);
|
||||
if (integration is null ||
|
||||
integration.Type != IntegrationType.Teams ||
|
||||
integration.Configuration is not null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Verify Organization matches hash
|
||||
if (!oAuthState.ValidateOrg(integration.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var callbackUrl = Url.RouteUrl(
|
||||
routeName: "TeamsIntegration_Create",
|
||||
values: null,
|
||||
protocol: currentContext.HttpContext.Request.Scheme,
|
||||
host: currentContext.HttpContext.Request.Host.ToUriComponent()
|
||||
);
|
||||
if (string.IsNullOrEmpty(callbackUrl))
|
||||
{
|
||||
throw new BadRequestException("Unable to build callback Url");
|
||||
}
|
||||
|
||||
var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
throw new BadRequestException("Invalid response from Teams.");
|
||||
}
|
||||
|
||||
var teams = await teamsService.GetJoinedTeamsAsync(token);
|
||||
|
||||
if (!teams.Any())
|
||||
{
|
||||
throw new BadRequestException("No teams were found.");
|
||||
}
|
||||
|
||||
var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams);
|
||||
integration.Configuration = JsonSerializer.Serialize(teamsIntegration);
|
||||
await integrationRepository.UpsertAsync(integration);
|
||||
|
||||
var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}";
|
||||
return Created(location, new OrganizationIntegrationResponseModel(integration));
|
||||
}
|
||||
|
||||
[Route("integrations/teams/incoming")]
|
||||
[AllowAnonymous]
|
||||
[HttpPost]
|
||||
public async Task IncomingPostAsync()
|
||||
{
|
||||
await adapter.ProcessAsync(Request, Response, bot);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
case IntegrationType.Teams:
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
default:
|
||||
return false;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
|
||||
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]);
|
||||
break;
|
||||
case IntegrationType.Slack:
|
||||
case IntegrationType.Slack or IntegrationType.Teams:
|
||||
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]);
|
||||
break;
|
||||
case IntegrationType.Webhook:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
@@ -35,6 +37,16 @@ public class OrganizationIntegrationResponseModel : ResponseModel
|
||||
? OrganizationIntegrationStatus.Initiated
|
||||
: OrganizationIntegrationStatus.Completed,
|
||||
|
||||
// If present and the configuration is null, OAuth has been initiated, and we are
|
||||
// waiting on the return OAuth call. If Configuration is not null and IsCompleted is true,
|
||||
// then we've received the app install bot callback, and it's Completed. Otherwise,
|
||||
// it is In Progress while we await the app install bot callback.
|
||||
IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration)
|
||||
? OrganizationIntegrationStatus.Initiated
|
||||
: (JsonSerializer.Deserialize<TeamsIntegration>(Configuration)?.IsCompleted ?? false)
|
||||
? OrganizationIntegrationStatus.Completed
|
||||
: OrganizationIntegrationStatus.InProgress,
|
||||
|
||||
// HEC and Datadog should only be allowed to be created non-null.
|
||||
// If they are null, they are Invalid
|
||||
IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration)
|
||||
|
||||
@@ -70,6 +70,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
UseRiskInsights = organization.UseRiskInsights;
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -118,6 +119,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public bool UseRiskInsights { get; set; }
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
|
||||
@@ -87,6 +87,8 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
|
||||
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
|
||||
}
|
||||
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -164,4 +166,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
public bool IsAdminInitiated { get; set; }
|
||||
public bool SsoEnabled { get; set; }
|
||||
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
}
|
||||
|
||||
@@ -52,5 +52,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
|
||||
UseRiskInsights = organization.UseRiskInsights;
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Kdf;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -40,6 +41,7 @@ public class AccountsController : Controller
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly ITwoFactorEmailService _twoFactorEmailService;
|
||||
private readonly IChangeKdfCommand _changeKdfCommand;
|
||||
|
||||
@@ -53,6 +55,7 @@ public class AccountsController : Controller
|
||||
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IFeatureService featureService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
ITwoFactorEmailService twoFactorEmailService,
|
||||
IChangeKdfCommand changeKdfCommand
|
||||
)
|
||||
@@ -66,6 +69,7 @@ public class AccountsController : Controller
|
||||
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_featureService = featureService;
|
||||
_userAccountKeysQuery = userAccountKeysQuery;
|
||||
_twoFactorEmailService = twoFactorEmailService;
|
||||
_changeKdfCommand = changeKdfCommand;
|
||||
}
|
||||
@@ -332,7 +336,9 @@ public class AccountsController : Controller
|
||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
|
||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||
var accountKeys = await _userAccountKeysQuery.Run(user);
|
||||
|
||||
var response = new ProfileResponseModel(user, accountKeys, organizationUserDetails, providerUserDetails,
|
||||
providerUserOrganizationDetails, twoFactorEnabled,
|
||||
hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||
return response;
|
||||
@@ -364,8 +370,9 @@ public class AccountsController : Controller
|
||||
var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
var userAccountKeys = await _userAccountKeysQuery.Run(user);
|
||||
|
||||
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||
var response = new ProfileResponseModel(user, userAccountKeys, null, null, null, twoFactorEnabled, hasPremiumFromOrg, organizationIdsClaimingActiveUser);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -389,8 +396,9 @@ public class AccountsController : Controller
|
||||
var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
var accountKeys = await _userAccountKeysQuery.Run(user);
|
||||
|
||||
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||
var response = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
@@ -8,6 +9,7 @@ using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -21,7 +23,8 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class AccountsController(
|
||||
IUserService userService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) : Controller
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery) : Controller
|
||||
{
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
@@ -58,8 +61,9 @@ public class AccountsController(
|
||||
var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
var accountKeys = await userAccountKeysQuery.Run(user);
|
||||
|
||||
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled,
|
||||
var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled,
|
||||
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||
return new PaymentResponseModel
|
||||
{
|
||||
|
||||
@@ -38,9 +38,7 @@ public class OrganizationBillingController(
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var response = OrganizationMetadataResponse.From(metadata);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
return TypedResults.Ok(metadata);
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
|
||||
@@ -3,7 +3,7 @@ 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")]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ public class ProviderBillingController(
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] });
|
||||
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] });
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Api.Billing.Models.Requests.Subscriptions;
|
||||
using Bit.Api.Billing.Models.Requirements;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
@@ -25,6 +26,7 @@ public class OrganizationBillingVNextController(
|
||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||
IGetBillingAddressQuery getBillingAddressQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetOrganizationMetadataQuery getOrganizationMetadataQuery,
|
||||
IGetOrganizationWarningsQuery getOrganizationWarningsQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IRestartSubscriptionCommand restartSubscriptionCommand,
|
||||
@@ -113,6 +115,23 @@ public class OrganizationBillingVNextController(
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[Authorize<MemberOrProviderRequirement>]
|
||||
[HttpGet("metadata")]
|
||||
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
|
||||
[InjectOrganization]
|
||||
public async Task<IResult> GetMetadataAsync(
|
||||
[BindNever] Organization organization)
|
||||
{
|
||||
var metadata = await getOrganizationMetadataQuery.Run(organization);
|
||||
|
||||
if (metadata == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(metadata);
|
||||
}
|
||||
|
||||
[Authorize<MemberOrProviderRequirement>]
|
||||
[HttpGet("warnings")]
|
||||
[InjectOrganization]
|
||||
|
||||
@@ -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,31 +0,0 @@
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record OrganizationMetadataResponse(
|
||||
bool IsEligibleForSelfHost,
|
||||
bool IsManaged,
|
||||
bool IsOnSecretsManagerStandalone,
|
||||
bool IsSubscriptionUnpaid,
|
||||
bool HasSubscription,
|
||||
bool HasOpenInvoice,
|
||||
bool IsSubscriptionCanceled,
|
||||
DateTime? InvoiceDueDate,
|
||||
DateTime? InvoiceCreatedDate,
|
||||
DateTime? SubPeriodEndDate,
|
||||
int OrganizationOccupiedSeats)
|
||||
{
|
||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||
=> new(
|
||||
metadata.IsEligibleForSelfHost,
|
||||
metadata.IsManaged,
|
||||
metadata.IsOnSecretsManagerStandalone,
|
||||
metadata.IsSubscriptionUnpaid,
|
||||
metadata.HasSubscription,
|
||||
metadata.HasOpenInvoice,
|
||||
metadata.IsSubscriptionCanceled,
|
||||
metadata.InvoiceDueDate,
|
||||
metadata.InvoiceCreatedDate,
|
||||
metadata.SubPeriodEndDate,
|
||||
metadata.OrganizationOccupiedSeats);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
@@ -10,7 +11,7 @@ namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record ProviderSubscriptionResponse(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
DateTime? CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
string CollectionMethod,
|
||||
IEnumerable<ProviderPlanResponse> Plans,
|
||||
@@ -51,10 +52,12 @@ public record ProviderSubscriptionResponse(
|
||||
|
||||
var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100;
|
||||
|
||||
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
|
||||
|
||||
return new ProviderSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
subscription.GetCurrentPeriodEnd(),
|
||||
discount?.Coupon?.PercentOff,
|
||||
subscription.CollectionMethod,
|
||||
providerPlanResponses,
|
||||
accountCredit,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
|
||||
[Route("users")]
|
||||
[Authorize("Application")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public UsersController(
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet("{id}/public-key")]
|
||||
public async Task<UserKeyResponseModel> Get(string id)
|
||||
{
|
||||
var guidId = new Guid(id);
|
||||
var key = await _userRepository.GetPublicKeyAsync(guidId);
|
||||
if (key == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new UserKeyResponseModel(guidId, key);
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,7 @@ public class AccountsKeyManagementController : Controller
|
||||
{
|
||||
OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash,
|
||||
|
||||
UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey,
|
||||
AccountPublicKey = model.AccountKeys.AccountPublicKey,
|
||||
AccountKeys = model.AccountKeys.ToAccountKeysData(),
|
||||
|
||||
MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(),
|
||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData),
|
||||
|
||||
39
src/Api/KeyManagement/Controllers/UsersController.cs
Normal file
39
src/Api/KeyManagement/Controllers/UsersController.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using UserKeyResponseModel = Bit.Api.Models.Response.UserKeyResponseModel;
|
||||
|
||||
|
||||
namespace Bit.Api.KeyManagement.Controllers;
|
||||
|
||||
[Route("users")]
|
||||
[Authorize("Application")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
|
||||
public UsersController(IUserRepository userRepository, IUserAccountKeysQuery userAccountKeysQuery)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userAccountKeysQuery = userAccountKeysQuery;
|
||||
}
|
||||
|
||||
[HttpGet("{id}/public-key")]
|
||||
public async Task<UserKeyResponseModel> GetPublicKeyAsync([FromRoute] Guid id)
|
||||
{
|
||||
var key = await _userRepository.GetPublicKeyAsync(id) ?? throw new NotFoundException();
|
||||
return new UserKeyResponseModel(id, key);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/keys")]
|
||||
public async Task<PublicKeysResponseModel> GetAccountKeysAsync([FromRoute] Guid id)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(id) ?? throw new NotFoundException();
|
||||
var accountKeys = await _userAccountKeysQuery.Run(user) ?? throw new NotFoundException("User account keys not found.");
|
||||
return new PublicKeysResponseModel(accountKeys);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
@@ -7,4 +8,44 @@ public class AccountKeysRequestModel
|
||||
{
|
||||
[EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; }
|
||||
public required string AccountPublicKey { get; set; }
|
||||
|
||||
public PublicKeyEncryptionKeyPairRequestModel? PublicKeyEncryptionKeyPair { get; set; }
|
||||
public SignatureKeyPairRequestModel? SignatureKeyPair { get; set; }
|
||||
public SecurityStateModel? SecurityState { get; set; }
|
||||
|
||||
public UserAccountKeysData ToAccountKeysData()
|
||||
{
|
||||
// This will be cleaned up, after a compatibility period, at which point PublicKeyEncryptionKeyPair and SignatureKeyPair will be required.
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-23751
|
||||
if (PublicKeyEncryptionKeyPair == null)
|
||||
{
|
||||
return new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData
|
||||
(
|
||||
UserKeyEncryptedAccountPrivateKey,
|
||||
AccountPublicKey
|
||||
),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
if (SignatureKeyPair == null || SecurityState == null)
|
||||
{
|
||||
return new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(),
|
||||
SignatureKeyPairData = SignatureKeyPair.ToSignatureKeyPairData(),
|
||||
SecurityStateData = SecurityState.ToSecurityState()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
public class PublicKeyEncryptionKeyPairRequestModel
|
||||
{
|
||||
[EncryptedString] public required string WrappedPrivateKey { get; set; }
|
||||
public required string PublicKey { get; set; }
|
||||
public string? SignedPublicKey { get; set; }
|
||||
|
||||
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
|
||||
{
|
||||
return new PublicKeyEncryptionKeyPairData(
|
||||
WrappedPrivateKey,
|
||||
PublicKey,
|
||||
SignedPublicKey
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
public class SignatureKeyPairRequestModel
|
||||
{
|
||||
public required string SignatureAlgorithm { get; set; }
|
||||
[EncryptedString] public required string WrappedSigningKey { get; set; }
|
||||
public required string VerifyingKey { get; set; }
|
||||
|
||||
public SignatureKeyPairData ToSignatureKeyPairData()
|
||||
{
|
||||
if (SignatureAlgorithm != "ed25519")
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Unsupported signature algorithm: {SignatureAlgorithm}"
|
||||
);
|
||||
}
|
||||
var algorithm = Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519;
|
||||
|
||||
return new SignatureKeyPairData(
|
||||
algorithm,
|
||||
WrappedSigningKey,
|
||||
VerifyingKey
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationVerifyBankRequestModel
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 99)]
|
||||
public int? Amount1 { get; set; }
|
||||
[Required]
|
||||
[Range(1, 99)]
|
||||
public int? Amount2 { get; set; }
|
||||
}
|
||||
@@ -5,6 +5,8 @@ using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.AdminConsole.Models.Response.Providers;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
@@ -13,6 +15,7 @@ namespace Bit.Api.Models.Response;
|
||||
public class ProfileResponseModel : ResponseModel
|
||||
{
|
||||
public ProfileResponseModel(User user,
|
||||
UserAccountKeysData userAccountKeysData,
|
||||
IEnumerable<OrganizationUserOrganizationDetails> organizationsUserDetails,
|
||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||
@@ -35,6 +38,7 @@ public class ProfileResponseModel : ResponseModel
|
||||
TwoFactorEnabled = twoFactorEnabled;
|
||||
Key = user.Key;
|
||||
PrivateKey = user.PrivateKey;
|
||||
AccountKeys = userAccountKeysData != null ? new PrivateKeysResponseModel(userAccountKeysData) : null;
|
||||
SecurityStamp = user.SecurityStamp;
|
||||
ForcePasswordReset = user.ForcePasswordReset;
|
||||
UsesKeyConnector = user.UsesKeyConnector;
|
||||
@@ -60,7 +64,9 @@ public class ProfileResponseModel : ResponseModel
|
||||
public string Culture { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public string Key { get; set; }
|
||||
[Obsolete("Use AccountKeys instead.")]
|
||||
public string PrivateKey { get; set; }
|
||||
public PrivateKeysResponseModel AccountKeys { get; set; }
|
||||
public string SecurityStamp { get; set; }
|
||||
public bool ForcePasswordReset { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
|
||||
@@ -229,8 +229,9 @@ public class Startup
|
||||
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
|
||||
}
|
||||
|
||||
// Add SlackService for OAuth API requests - if configured
|
||||
// Add Slack / Teams Services for OAuth API requests - if configured
|
||||
services.AddSlackService(globalSettings);
|
||||
services.AddTeamsService(globalSettings);
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
@@ -325,6 +326,6 @@ public class Startup
|
||||
}
|
||||
|
||||
// Log startup
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
|
||||
logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ public class SendsController : Controller
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
|
||||
else
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ExceptionHandlerFilterAttribute>>();
|
||||
logger.LogError(0, exception, exception.Message);
|
||||
logger.LogError(0, exception, "Unhandled exception");
|
||||
errorMessage = "An unhandled server error has occurred.";
|
||||
context.HttpContext.Response.StatusCode = 500;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -754,6 +755,11 @@ public class CiphersController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Cannot move an archived item to an organization.");
|
||||
}
|
||||
|
||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||
|
||||
var original = cipher.Clone();
|
||||
@@ -1263,6 +1269,11 @@ public class CiphersController : Controller
|
||||
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
|
||||
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
|
||||
}
|
||||
|
||||
if (cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Cannot move archived items to an organization.");
|
||||
}
|
||||
}
|
||||
|
||||
var shareCiphers = new List<(CipherDetails, DateTime?)>();
|
||||
@@ -1275,6 +1286,11 @@ public class CiphersController : Controller
|
||||
|
||||
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
||||
|
||||
if (existingCipher.ArchivedDate.HasValue)
|
||||
{
|
||||
throw new BadRequestException("Cannot move archived items to an organization.");
|
||||
}
|
||||
|
||||
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
||||
}
|
||||
|
||||
@@ -1351,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,
|
||||
@@ -1404,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1425,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(
|
||||
@@ -1454,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);
|
||||
@@ -1500,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1578,7 +1604,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1615,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -42,6 +44,7 @@ public class SyncController : Controller
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
|
||||
public SyncController(
|
||||
IUserService userService,
|
||||
@@ -57,7 +60,8 @@ public class SyncController : Controller
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery)
|
||||
{
|
||||
_userService = userService;
|
||||
_folderRepository = folderRepository;
|
||||
@@ -73,6 +77,7 @@ public class SyncController : Controller
|
||||
_featureService = featureService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_userAccountKeysQuery = userAccountKeysQuery;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@@ -116,7 +121,14 @@ public class SyncController : Controller
|
||||
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
|
||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||
UserAccountKeysData userAccountKeys = null;
|
||||
// JIT TDE users and some broken/old users may not have a private key.
|
||||
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
|
||||
{
|
||||
userAccountKeys = await _userAccountKeysQuery.Run(user);
|
||||
}
|
||||
|
||||
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||
return response;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Response;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
@@ -24,6 +25,7 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
public SyncResponseModel(
|
||||
GlobalSettings globalSettings,
|
||||
User user,
|
||||
UserAccountKeysData userAccountKeysData,
|
||||
bool userTwoFactorEnabled,
|
||||
bool userHasPremiumFromOrganization,
|
||||
IDictionary<Guid, OrganizationAbility> organizationAbilities,
|
||||
@@ -40,7 +42,7 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
IEnumerable<Send> sends)
|
||||
: this()
|
||||
{
|
||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||
Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
|
||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser);
|
||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||
Ciphers = ciphers.Select(cipher =>
|
||||
|
||||
@@ -7,9 +7,7 @@ public class BillingSettings
|
||||
{
|
||||
public virtual string JobsKey { get; set; }
|
||||
public virtual string StripeWebhookKey { get; set; }
|
||||
public virtual string StripeWebhookSecret { get; set; }
|
||||
public virtual string StripeWebhookSecret20231016 { get; set; }
|
||||
public virtual string StripeWebhookSecret20240620 { 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();
|
||||
@@ -44,6 +42,15 @@ public class BillingSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string BaseUrl { get; set; }
|
||||
public virtual string Path { get; set; }
|
||||
public virtual int PersonaId { get; set; }
|
||||
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
|
||||
|
||||
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
|
||||
}
|
||||
public class SearchSettings
|
||||
{
|
||||
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
|
||||
public virtual bool RealTime { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
@@ -35,7 +32,7 @@ public class FreshdeskController : Controller
|
||||
GlobalSettings globalSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
@@ -101,7 +98,8 @@ public class FreshdeskController : Controller
|
||||
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
|
||||
}
|
||||
|
||||
var planName = GetAttribute<DisplayAttribute>(org.PlanType).Name.Split(" ").FirstOrDefault();
|
||||
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
|
||||
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(planName))
|
||||
{
|
||||
tags.Add(string.Format("Org: {0}", planName));
|
||||
@@ -159,28 +157,22 @@ public class FreshdeskController : Controller
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
|
||||
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
|
||||
// Get response from Onyx AI
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequestModel),
|
||||
JsonSerializer.Serialize(onyxJsonResponse));
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the answer as a note to the ticket
|
||||
await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
|
||||
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -206,27 +198,21 @@ public class FreshdeskController : Controller
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
|
||||
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequestModel),
|
||||
JsonSerializer.Serialize(onyxJsonResponse));
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the reply to the ticket
|
||||
await AddReplyToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
|
||||
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -356,7 +342,32 @@ public class FreshdeskController : Controller
|
||||
return await CallFreshdeskApiAsync(request, retriedCount++);
|
||||
}
|
||||
|
||||
private async Task<(HttpResponseMessage, T)> CallOnyxApi<T>(HttpRequestMessage request)
|
||||
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
|
||||
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
|
||||
{
|
||||
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
|
||||
return (onyxRequest, onyxResponse);
|
||||
}
|
||||
|
||||
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
|
||||
{
|
||||
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
|
||||
return (request, onyxSimpleResponse);
|
||||
}
|
||||
|
||||
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
@@ -365,7 +376,7 @@ public class FreshdeskController : Controller
|
||||
{
|
||||
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
|
||||
response.StatusCode, JsonSerializer.Serialize(response));
|
||||
return (null, default);
|
||||
return new T();
|
||||
}
|
||||
var responseStr = await response.Content.ReadAsStringAsync();
|
||||
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
|
||||
@@ -373,11 +384,12 @@ public class FreshdeskController : Controller
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
|
||||
return (response, responseJson);
|
||||
return responseJson ?? new T();
|
||||
}
|
||||
|
||||
private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
|
||||
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
|
||||
{
|
||||
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();
|
||||
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
|
||||
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,7 @@ public class StripeController : Controller
|
||||
|
||||
return deliveryContainer.ApiVersion switch
|
||||
{
|
||||
"2024-06-20" => HandleVersionWith(_billingSettings.StripeWebhookSecret20240620),
|
||||
"2023-10-16" => HandleVersionWith(_billingSettings.StripeWebhookSecret20231016),
|
||||
"2022-08-01" => HandleVersionWith(_billingSettings.StripeWebhookSecret),
|
||||
"2025-08-27.basil" => HandleVersionWith(_billingSettings.StripeWebhookSecret20250827Basil),
|
||||
_ => HandleDefault(deliveryContainer.ApiVersion)
|
||||
};
|
||||
|
||||
|
||||
@@ -1,35 +1,58 @@
|
||||
// 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 static Bit.Billing.BillingSettings;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxAnswerWithCitationRequestModel
|
||||
public class OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("messages")]
|
||||
public List<Message> Messages { get; set; }
|
||||
|
||||
[JsonPropertyName("persona_id")]
|
||||
public int PersonaId { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("retrieval_options")]
|
||||
public RetrievalOptions RetrievalOptions { get; set; }
|
||||
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message, int personaId = 1)
|
||||
public OnyxRequestModel(OnyxSettings onyxSettings)
|
||||
{
|
||||
PersonaId = onyxSettings.PersonaId;
|
||||
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
|
||||
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /query/answer-with-citation
|
||||
/// which has been deprecated. This can be removed once later
|
||||
/// </summary>
|
||||
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("messages")]
|
||||
public List<Message> Messages { get; set; } = new List<Message>();
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
Messages = new List<Message>() { new Message() { MessageText = message } };
|
||||
RetrievalOptions = new RetrievalOptions();
|
||||
PersonaId = personaId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /chat/send-message-simple-api
|
||||
/// </summary>
|
||||
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
public class Message
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string MessageText { get; set; }
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sender")]
|
||||
public string Sender { get; set; } = "user";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxAnswerWithCitationResponseModel
|
||||
{
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; set; }
|
||||
|
||||
[JsonPropertyName("rephrase")]
|
||||
public string Rephrase { get; set; }
|
||||
|
||||
[JsonPropertyName("citations")]
|
||||
public List<Citation> Citations { get; set; }
|
||||
|
||||
[JsonPropertyName("llm_selected_doc_indices")]
|
||||
public List<int> LlmSelectedDocIndices { get; set; }
|
||||
|
||||
[JsonPropertyName("error_msg")]
|
||||
public string ErrorMsg { get; set; }
|
||||
}
|
||||
|
||||
public class Citation
|
||||
{
|
||||
[JsonPropertyName("citation_num")]
|
||||
public int CitationNum { get; set; }
|
||||
|
||||
[JsonPropertyName("document_id")]
|
||||
public string DocumentId { get; set; }
|
||||
}
|
||||
15
src/Billing/Models/OnyxResponseModel.cs
Normal file
15
src/Billing/Models/OnyxResponseModel.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxResponseModel
|
||||
{
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("answer_citationless")]
|
||||
public string AnswerCitationless { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("error_msg")]
|
||||
public string ErrorMsg { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Event = Stripe.Event;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
@@ -35,13 +36,13 @@ public class InvoiceCreatedHandler(
|
||||
if (usingPayPal && invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason:
|
||||
"subscription_create" or
|
||||
"subscription_cycle" or
|
||||
"automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null and not ""
|
||||
Parent.SubscriptionDetails: not null
|
||||
})
|
||||
{
|
||||
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Stripe;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Stripe;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
@@ -26,17 +27,20 @@ public class PaymentFailedHandler : IPaymentFailedHandler
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
|
||||
if (invoice.Status == StripeConstants.InvoiceStatus.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
// attempt count 4 = 11 days after initial failure
|
||||
if (invoice.AttemptCount <= 3 ||
|
||||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
|
||||
if (invoice.Parent?.SubscriptionDetails != null)
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
|
||||
// attempt count 4 = 11 days after initial failure
|
||||
if (invoice.AttemptCount <= 3 ||
|
||||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +48,9 @@ public class PaymentFailedHandler : IPaymentFailedHandler
|
||||
invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null
|
||||
Parent.SubscriptionDetails: not null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -29,12 +31,17 @@ public class PaymentSucceededHandler(
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
|
||||
if (invoice.Status != StripeConstants.InvoiceStatus.Paid || invoice.BillingReason != "subscription_create")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
if (invoice.Parent?.SubscriptionDetails == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
|
||||
if (subscription?.Status != StripeSubscriptionStatus.Active)
|
||||
{
|
||||
return;
|
||||
@@ -96,7 +103,7 @@ public class PaymentSucceededHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
|
||||
organization = await organizationRepository.GetByIdAsync(organization.Id);
|
||||
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
|
||||
}
|
||||
@@ -107,7 +114,7 @@ public class PaymentSucceededHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
await userService.EnablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,14 @@ public class ProviderEventService(
|
||||
return;
|
||||
}
|
||||
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["discounts"]);
|
||||
|
||||
var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
|
||||
if (invoice.Parent is not { Type: "subscription_details" })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = (await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId)).Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
var hasProviderId = metadata.TryGetValue("providerId", out var providerId);
|
||||
|
||||
@@ -68,7 +73,9 @@ public class ProviderEventService(
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
|
||||
|
||||
var discountedPercentage = (100 - totalPercentOff) / 100;
|
||||
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
@@ -96,7 +103,9 @@ public class ProviderEventService(
|
||||
|
||||
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||
|
||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||
var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0;
|
||||
|
||||
var discountedPercentage = (100 - totalPercentOff) / 100;
|
||||
|
||||
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -87,25 +88,6 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
/// <returns></returns>
|
||||
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
|
||||
{
|
||||
Guid? organizationId = null;
|
||||
Guid? userId = null;
|
||||
Guid? providerId = null;
|
||||
|
||||
if (charge.InvoiceId != null)
|
||||
{
|
||||
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
|
||||
if (invoice?.SubscriptionId != null)
|
||||
{
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
|
||||
{
|
||||
return (organizationId, userId, providerId);
|
||||
}
|
||||
|
||||
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
|
||||
{
|
||||
Customer = charge.CustomerId
|
||||
@@ -118,7 +100,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
continue;
|
||||
}
|
||||
|
||||
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
||||
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
|
||||
{
|
||||
@@ -256,10 +238,10 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null
|
||||
Parent.SubscriptionDetails: not null
|
||||
};
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
|
||||
@@ -272,7 +254,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
||||
if (invoice.Parent?.SubscriptionDetails == null)
|
||||
{
|
||||
_logger.LogWarning("Invoice parent was not a subscription.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId);
|
||||
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
||||
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using Event = Stripe.Event;
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
@@ -50,11 +51,11 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
|
||||
return;
|
||||
}
|
||||
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -82,12 +84,14 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
|
||||
|
||||
switch (subscription.Status)
|
||||
{
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
|
||||
when organizationId.HasValue:
|
||||
{
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
|
||||
if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
|
||||
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
|
||||
{
|
||||
@@ -114,7 +118,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
await VoidOpenInvoices(subscription.Id);
|
||||
}
|
||||
|
||||
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -154,7 +158,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
{
|
||||
if (userId.HasValue)
|
||||
{
|
||||
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -162,17 +166,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
|
||||
{
|
||||
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
|
||||
}
|
||||
|
||||
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
|
||||
await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,9 +284,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
?.Coupon
|
||||
?.Id == "sm-standalone";
|
||||
|
||||
var subscriptionHasSecretsManagerTrial = subscription.Discount
|
||||
?.Coupon
|
||||
?.Id == "sm-standalone";
|
||||
var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id)
|
||||
.Contains(StripeConstants.CouponIDs.SecretsManagerStandalone);
|
||||
|
||||
if (customerHasSecretsManagerTrial)
|
||||
{
|
||||
|
||||
@@ -36,17 +36,16 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
||||
|
||||
if (string.IsNullOrEmpty(invoice.SubscriptionId))
|
||||
var customer =
|
||||
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
|
||||
|
||||
var subscription = customer.Subscriptions.FirstOrDefault();
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer.tax", "customer.tax_ids"]
|
||||
});
|
||||
|
||||
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
|
||||
if (organizationId.HasValue)
|
||||
@@ -58,7 +57,7 @@ public class UpcomingInvoiceHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id);
|
||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
@@ -137,7 +136,7 @@ public class UpcomingInvoiceHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id);
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
|
||||
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
|
||||
}
|
||||
@@ -199,13 +198,14 @@ public class UpcomingInvoiceHandler(
|
||||
private async Task AlignOrganizationTaxConcernsAsync(
|
||||
Organization organization,
|
||||
Subscription subscription,
|
||||
Customer customer,
|
||||
string eventId)
|
||||
{
|
||||
var nonUSBusinessUse =
|
||||
organization.PlanType.GetProductTier() != ProductTierType.Families &&
|
||||
subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
||||
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
||||
|
||||
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -246,10 +246,11 @@ public class UpcomingInvoiceHandler(
|
||||
private async Task AlignProviderTaxConcernsAsync(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
Customer customer,
|
||||
string eventId)
|
||||
{
|
||||
if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
||||
subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
||||
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user