mirror of
https://github.com/bitwarden/server.git
synced 2025-06-27 14:16:19 -05:00
Merge branch 'main' into ac/pm-22101/enforce-restrictions-on-default-collection
This commit is contained in:
commit
aa655b6024
@ -3,6 +3,9 @@ services:
|
||||
image: mcr.microsoft.com/devcontainers/dotnet:8.0
|
||||
volumes:
|
||||
- ../../:/workspace:cached
|
||||
env_file:
|
||||
- path: ../../dev/.env
|
||||
required: false
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
|
@ -1,17 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
export DEV_DIR=/workspace/dev
|
||||
export REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev
|
||||
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
get_installation_id_and_key() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
echo "Please enter your installation id and key from https://bitwarden.com/host:"
|
||||
read -r -p "Installation id: " INSTALLATION_ID
|
||||
read -r -p "Installation key: " INSTALLATION_KEY
|
||||
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
|
||||
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
|
||||
secrets.json.example >secrets.json # create/overwrite secrets.json
|
||||
popd >/dev/null || exit
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
allow_interactive=1
|
||||
else
|
||||
echo "Doing non-interactive setup"
|
||||
allow_interactive=0
|
||||
fi
|
||||
|
||||
get_option() {
|
||||
# Helper function for reading the value of an environment variable
|
||||
# primarily but then falling back to an interactive question if allowed
|
||||
# and lastly falling back to a default value input when either other
|
||||
# option is available.
|
||||
name_of_var="$1"
|
||||
question_text="$2"
|
||||
default_value="$3"
|
||||
is_secret="$4"
|
||||
|
||||
if [[ -n "${!name_of_var}" ]]; then
|
||||
# If the env variable they gave us has a value, then use that value
|
||||
echo "${!name_of_var}"
|
||||
elif [[ "$allow_interactive" == 1 ]]; then
|
||||
# If we can be interactive, then use the text they gave us to request input
|
||||
if [[ "$is_secret" == 1 ]]; then
|
||||
read -r -s -p "$question_text" response
|
||||
echo "$response"
|
||||
else
|
||||
read -r -p "$question_text" response
|
||||
echo "$response"
|
||||
fi
|
||||
else
|
||||
# If no environment variable and not interactive, then just give back default value
|
||||
echo "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_comments() {
|
||||
@ -26,51 +51,70 @@ remove_comments() {
|
||||
|
||||
configure_other_vars() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
cp secrets.json .secrets.json.tmp
|
||||
cp "$REPO_ROOT/dev/secrets.json" "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
# set DB_PASSWORD equal to .services.mssql.environment.MSSQL_SA_PASSWORD, accounting for quotes
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $DEV_DIR/.env)"
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $REPO_ROOT/dev/.env)"
|
||||
SQL_CONNECTION_STRING="Server=localhost;Database=vault_dev;User Id=SA;Password=$DB_PASSWORD;Encrypt=True;TrustServerCertificate=True"
|
||||
jq \
|
||||
".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" |
|
||||
.globalSettings.postgreSql.connectionString = \"Host=localhost;Username=postgres;Password=$DB_PASSWORD;Database=vault_dev;Include Error Detail=true\" |
|
||||
.globalSettings.mySql.connectionString = \"server=localhost;uid=root;pwd=$DB_PASSWORD;database=vault_dev\"" \
|
||||
.secrets.json.tmp >secrets.json
|
||||
rm .secrets.json.tmp
|
||||
rm "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
popd >/dev/null || exit
|
||||
}
|
||||
|
||||
one_time_setup() {
|
||||
read -r -p \
|
||||
"Would you like to configure your secrets and certificates for the first time?
|
||||
WARNING: This will overwrite any existing secrets.json and certificate files.
|
||||
Proceed? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running one-time setup script..."
|
||||
sleep 1
|
||||
read -r -p \
|
||||
"Place the secrets.json and dev.pfx files from our shared Collection in the ./dev directory.
|
||||
if [[ ! -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
# We do not have the cert file
|
||||
if [[ ! -z "${DEV_CERT_CONTENTS}" ]]; then
|
||||
# Make file for them
|
||||
echo "Making $REPO_ROOT/dev/dev.pfx file for you based on DEV_CERT_CONTENTS environment variable."
|
||||
# Assume content is base64 encoded
|
||||
echo "$DEV_CERT_CONTENTS" | base64 -d > "$REPO_ROOT/dev/dev.pfx"
|
||||
else
|
||||
if [[ $allow_interactive -eq 1 ]]; then
|
||||
read -r -p \
|
||||
"Place the dev.pfx files from our shared Collection in the $REPO_ROOT/dev directory.
|
||||
Press <Enter> to continue."
|
||||
remove_comments ./dev/secrets.json
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
cert_password="$(get_option "DEV_CERT_PASSWORD" "Paste the \"Licensing Certificate - Dev\" password: " "" 1)"
|
||||
certificate-tool add --file "$REPO_ROOT/dev/dev.pfx" --password "$cert_password"
|
||||
else
|
||||
echo "You don't have a $REPO_ROOT/dev/dev.pfx file setup." >/dev/stderr
|
||||
fi
|
||||
|
||||
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like us to setup your secrets.json file for you? [y/N] " "n")"
|
||||
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
remove_comments "$REPO_ROOT/dev/secrets.json"
|
||||
configure_other_vars
|
||||
# setup_secrets needs to be ran from the dev folder
|
||||
pushd "$REPO_ROOT/dev" >/dev/null || exit
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh "$REPO_ROOT/dev/setup_secrets.ps1" || true
|
||||
popd >/dev/null || exit
|
||||
fi
|
||||
|
||||
do_azurite_setup="$(get_option "SETUP_AZURITE" "Would you like us to setup your azurite environment? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Installing Az module. This will take ~a minute..."
|
||||
pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force"
|
||||
pwsh ./dev/setup_azurite.ps1
|
||||
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
|
||||
read -r -s -p "Paste the \"Licensing Certificate - Dev\" password: " CERT_PASSWORD
|
||||
echo
|
||||
pushd ./dev >/dev/null || exit
|
||||
certificate-tool add --file ./dev.pfx --password "$CERT_PASSWORD"
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh ./setup_secrets.ps1 || true
|
||||
popd >/dev/null || exit
|
||||
pwsh "$REPO_ROOT/dev/setup_azurite.ps1"
|
||||
fi
|
||||
|
||||
run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running migrations..."
|
||||
sleep 5 # wait for DB container to start
|
||||
dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING"
|
||||
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"
|
||||
fi
|
||||
read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response
|
||||
|
||||
stripe_response="$(get_option "INSTALL_STRIPE_CLI" "Would you like to install the Stripe CLI? [y/N] " "n")"
|
||||
if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
install_stripe_cli
|
||||
fi
|
||||
@ -88,11 +132,4 @@ install_stripe_cli() {
|
||||
sudo apt install -y stripe
|
||||
}
|
||||
|
||||
# main
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
one_time_setup
|
||||
else
|
||||
# Ignore interactive elements when running in codespaces since they are not supported there
|
||||
# TODO Write codespaces specific instructions and link here
|
||||
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
|
||||
fi
|
||||
one_time_setup
|
||||
|
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@ -350,14 +350,6 @@ jobs:
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- name: Make Docker stub checksums
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
run: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
@ -378,26 +370,6 @@ jobs:
|
||||
path: docker-stub-EU.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
path: docker-stub-US-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Public API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
|
3
.github/workflows/enforce-labels.yml
vendored
3
.github/workflows/enforce-labels.yml
vendored
@ -4,6 +4,9 @@ on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }}
|
||||
|
3
.github/workflows/protect-files.yml
vendored
3
.github/workflows/protect-files.yml
vendored
@ -16,6 +16,9 @@ jobs:
|
||||
changed-files:
|
||||
name: Check for file changes
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
changes: ${{steps.check-changes.outputs.changes_detected}}
|
||||
|
||||
|
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@ -17,6 +17,9 @@ on:
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
@ -65,9 +68,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
@ -78,9 +79,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
@ -88,9 +87,7 @@ jobs:
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
commit: ${{ github.sha }}
|
||||
tag: "v${{ needs.setup.outputs.release_version }}"
|
||||
|
125
.github/workflows/repository-management.yml
vendored
125
.github/workflows/repository-management.yml
vendored
@ -22,6 +22,8 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
@ -44,49 +46,11 @@ jobs:
|
||||
|
||||
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
needs: setup
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- 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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cut branch
|
||||
env:
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
git switch --quiet --create $BRANCH_NAME
|
||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||
|
||||
|
||||
bump_version:
|
||||
name: Bump Version
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- cut_branch
|
||||
- setup
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
@ -187,14 +151,13 @@ jobs:
|
||||
- name: Push changes
|
||||
run: git push
|
||||
|
||||
|
||||
cherry_pick:
|
||||
name: Cherry-Pick Commit(s)
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- bump_version
|
||||
- setup
|
||||
- bump_version
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
@ -203,78 +166,30 @@ jobs:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out main branch
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "actions@github.com"
|
||||
git config --local user.name "Github Actions"
|
||||
|
||||
- name: Install xmllint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-utils
|
||||
|
||||
- name: Perform cherry-pick(s)
|
||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||
env:
|
||||
CUT_BRANCH: ${{ needs.setup.outputs.branch }}
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
# Function for cherry-picking
|
||||
cherry_pick () {
|
||||
local source_branch=$1
|
||||
local destination_branch=$2
|
||||
|
||||
# Get project commit/version from source branch
|
||||
git switch $source_branch
|
||||
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
|
||||
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
|
||||
# Get project commit/version from destination branch
|
||||
git switch $destination_branch
|
||||
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
|
||||
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
|
||||
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
|
||||
git push -u origin $destination_branch
|
||||
fi
|
||||
}
|
||||
|
||||
# If we are cutting 'hotfix-rc':
|
||||
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
|
||||
|
||||
# If the 'rc' branch exists:
|
||||
if [[ $(git ls-remote --heads origin rc) ]]; then
|
||||
|
||||
# Chery-pick from 'rc' into 'hotfix-rc'
|
||||
cherry_pick rc hotfix-rc
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
# If the 'rc' branch does not exist:
|
||||
else
|
||||
|
||||
# Cherry-pick from 'main' into 'hotfix-rc'
|
||||
cherry_pick main hotfix-rc
|
||||
|
||||
fi
|
||||
|
||||
# If we are cutting 'rc':
|
||||
elif [[ "$CUT_BRANCH" == "rc" ]]; then
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
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
|
||||
|
||||
- name: Cut branch
|
||||
env:
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
git switch --quiet --create $BRANCH_NAME
|
||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||
|
||||
move_future_db_scripts:
|
||||
name: Move finalization database scripts
|
||||
needs: cherry_pick
|
||||
needs: cut_branch
|
||||
uses: ./.github/workflows/_move_finalization_db_scripts.yml
|
||||
secrets: inherit
|
||||
|
5
.github/workflows/stale-bot.yml
vendored
5
.github/workflows/stale-bot.yml
vendored
@ -8,6 +8,11 @@ jobs:
|
||||
stale:
|
||||
name: Check for stale issues and PRs
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
|
7
.github/workflows/test-database.yml
vendored
7
.github/workflows/test-database.yml
vendored
@ -31,10 +31,17 @@ on:
|
||||
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
||||
- "src/**/Entities/**/*.cs" # Database entity definitions
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.6.0</Version>
|
||||
<Version>2025.6.2</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -26,3 +26,12 @@ IDENTITY_PROXY_PORT=33756
|
||||
# Optional RabbitMQ configuration
|
||||
RABBITMQ_DEFAULT_USER=bitwarden
|
||||
RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
|
||||
|
||||
# Environment variables that help customize dev container start
|
||||
# Without these the dev container will ask these questions in an interactive manner
|
||||
# when possible (excluding running in GitHub Codespaces)
|
||||
# SETUP_SECRETS_JSON=yes
|
||||
# SETUP_AZURITE=yes
|
||||
# RUN_MSSQL_MIGRATIONS=yes
|
||||
# DEV_CERT_PASSWORD=dev_cert_password_here
|
||||
# INSTALL_STRIPE_CLI=no
|
||||
|
@ -6,6 +6,7 @@ using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
@ -34,14 +35,13 @@ namespace Bit.Admin.AdminConsole.Controllers;
|
||||
public class ProvidersController : Controller
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
@ -54,14 +54,13 @@ public class ProvidersController : Controller
|
||||
|
||||
public ProvidersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderService providerService,
|
||||
GlobalSettings globalSettings,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IUserService userService,
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
@ -71,14 +70,13 @@ public class ProvidersController : Controller
|
||||
IStripeAdapter stripeAdapter)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_providerService = providerService;
|
||||
_globalSettings = globalSettings;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_userService = userService;
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
@ -459,7 +457,7 @@ public class ProvidersController : Controller
|
||||
}
|
||||
|
||||
var organization = model.CreateOrganization(provider);
|
||||
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
|
||||
await _resellerClientOrganizationSignUpCommand.SignUpResellerClientAsync(organization, model.Owners);
|
||||
await _providerService.AddOrganization(providerId, organization.Id, null);
|
||||
|
||||
return RedirectToAction("Edit", "Providers", new { id = providerId });
|
||||
|
@ -403,16 +403,15 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task Confirm(string orgId, string id, [FromBody] OrganizationUserConfirmRequestModel model)
|
||||
public async Task Confirm(Guid orgId, Guid id, [FromBody] OrganizationUserConfirmRequestModel model)
|
||||
{
|
||||
var orgGuidId = new Guid(orgId);
|
||||
if (!await _currentContext.ManageUsers(orgGuidId))
|
||||
if (!await _currentContext.ManageUsers(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
|
||||
var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
@ -521,7 +520,9 @@ public class OrganizationUsersController : Controller
|
||||
.Concat(readonlyCollectionAccess)
|
||||
.ToList();
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId,
|
||||
var existingUserType = organizationUser.Type;
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
|
||||
collectionsToSave, groupsToSave);
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
|
@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
@ -60,6 +60,10 @@ public class OrganizationUserConfirmRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationUserBulkConfirmRequestModelEntry
|
||||
|
@ -177,9 +177,10 @@ public class MembersController : Controller
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var existingUserType = existingUser.Type;
|
||||
var updatedUser = model.ToOrganizationUser(existingUser);
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups);
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
|
||||
MemberResponseModel response = null;
|
||||
if (existingUser.UserId.HasValue)
|
||||
{
|
||||
|
@ -1,34 +1,21 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Api.KeyManagement.Validators;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -45,22 +32,9 @@ public class AccountsController : Controller
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
|
||||
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
|
||||
private readonly IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> _sendValidator;
|
||||
private readonly IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
|
||||
_emergencyAccessValidator;
|
||||
private readonly IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>,
|
||||
IReadOnlyList<OrganizationUser>>
|
||||
_organizationUserValidator;
|
||||
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
_webauthnKeyValidator;
|
||||
|
||||
|
||||
public AccountsController(
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -69,17 +43,8 @@ public class AccountsController : Controller
|
||||
IPolicyService policyService,
|
||||
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
|
||||
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
|
||||
IRotateUserKeyCommand rotateUserKeyCommand,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IFeatureService featureService,
|
||||
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
|
||||
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
|
||||
IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> sendValidator,
|
||||
IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator
|
||||
IFeatureService featureService
|
||||
)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
@ -89,15 +54,8 @@ public class AccountsController : Controller
|
||||
_policyService = policyService;
|
||||
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
|
||||
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
|
||||
_rotateUserKeyCommand = rotateUserKeyCommand;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_featureService = featureService;
|
||||
_cipherValidator = cipherValidator;
|
||||
_folderValidator = folderValidator;
|
||||
_sendValidator = sendValidator;
|
||||
_emergencyAccessValidator = emergencyAccessValidator;
|
||||
_organizationUserValidator = organizationUserValidator;
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
}
|
||||
|
||||
|
||||
@ -313,45 +271,6 @@ public class AccountsController : Controller
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
|
||||
[HttpPost("key")]
|
||||
public async Task PostKey([FromBody] UpdateKeyRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var dataModel = new RotateUserKeyData
|
||||
{
|
||||
MasterPasswordHash = model.MasterPasswordHash,
|
||||
Key = model.Key,
|
||||
PrivateKey = model.PrivateKey,
|
||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers),
|
||||
Folders = await _folderValidator.ValidateAsync(user, model.Folders),
|
||||
Sends = await _sendValidator.ValidateAsync(user, model.Sends),
|
||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys),
|
||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys),
|
||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.WebAuthnKeys)
|
||||
};
|
||||
|
||||
var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("security-stamp")]
|
||||
public async Task PostSecurityStamp([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
|
@ -3,7 +3,6 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
|
||||
@ -199,24 +198,6 @@ public class CollectionsController : Controller
|
||||
return new CollectionAccessDetailsResponseModel(collectionWithPermissions);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/users")]
|
||||
public async Task PutUsers(Guid orgId, Guid id, [FromBody] IEnumerable<SelectionReadOnlyRequestModel> model)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (collection.Type == CollectionType.DefaultUserCollection)
|
||||
{
|
||||
throw new BadRequestException("You cannot modify member access for collections with the type as DefaultUserCollection.");
|
||||
}
|
||||
|
||||
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
|
||||
}
|
||||
|
||||
[HttpPost("bulk-access")]
|
||||
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
|
||||
{
|
||||
@ -267,18 +248,4 @@ public class CollectionsController : Controller
|
||||
|
||||
await _deleteCollectionCommand.DeleteManyAsync(collections);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/user/{orgUserId}")]
|
||||
[HttpPost("{id}/delete-user/{orgUserId}")]
|
||||
public async Task DeleteUser(Guid orgId, Guid id, Guid orgUserId)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _collectionService.DeleteUserAsync(collection, orgUserId);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
using Bit.Api.Dirt.Models;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
@ -17,24 +18,36 @@ namespace Bit.Api.Dirt.Controllers;
|
||||
public class ReportsController : Controller
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||
private readonly IMemberAccessReportQuery _memberAccessReportQuery;
|
||||
private readonly IRiskInsightsReportQuery _riskInsightsReportQuery;
|
||||
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
|
||||
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
|
||||
private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand;
|
||||
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
|
||||
|
||||
public ReportsController(
|
||||
ICurrentContext currentContext,
|
||||
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||
IMemberAccessReportQuery memberAccessReportQuery,
|
||||
IRiskInsightsReportQuery riskInsightsReportQuery,
|
||||
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
|
||||
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand
|
||||
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
|
||||
IGetOrganizationReportQuery getOrganizationReportQuery,
|
||||
IAddOrganizationReportCommand addOrganizationReportCommand,
|
||||
IDropOrganizationReportCommand dropOrganizationReportCommand
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||
_memberAccessReportQuery = memberAccessReportQuery;
|
||||
_riskInsightsReportQuery = riskInsightsReportQuery;
|
||||
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
|
||||
_getOrganizationReportQuery = getOrganizationReportQuery;
|
||||
_addOrganizationReportCommand = addOrganizationReportCommand;
|
||||
_dropOrganizationReportCommand = dropOrganizationReportCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -54,9 +67,9 @@ public class ReportsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
var riskDetails = await GetRiskInsightsReportDetails(new RiskInsightsReportRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||
var responses = riskDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -69,16 +82,16 @@ public class ReportsController : Controller
|
||||
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||
[HttpGet("member-access/{orgId}")]
|
||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
public async Task<IEnumerable<MemberAccessDetailReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||
var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -87,13 +100,28 @@ public class ReportsController : Controller
|
||||
/// Contains the organization member info, the cipher ids associated with the member,
|
||||
/// and details on their collections, groups, and permissions
|
||||
/// </summary>
|
||||
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||
/// <param name="request">Request parameters</param>
|
||||
/// <returns>
|
||||
/// List of a user's permissions at a group and collection level as well as the number of ciphers
|
||||
/// associated with that group/collection
|
||||
/// </returns>
|
||||
private async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessDetails(
|
||||
MemberAccessReportRequest request)
|
||||
{
|
||||
var memberCipherDetails =
|
||||
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||
return memberCipherDetails;
|
||||
var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
|
||||
return accessDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids
|
||||
/// </summary>
|
||||
/// <param name="request">Request parameters</param>
|
||||
/// <returns>A list of risk insights data associating the user to cipher ids</returns>
|
||||
private async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(
|
||||
RiskInsightsReportRequest request)
|
||||
{
|
||||
var riskDetails = await _riskInsightsReportQuery.GetRiskInsightsReportDetails(request);
|
||||
return riskDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -185,4 +213,72 @@ public class ReportsController : Controller
|
||||
|
||||
await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new organization report
|
||||
/// </summary>
|
||||
/// <param name="request">A single instance of AddOrganizationReportRequest</param>
|
||||
/// <returns>A single instance of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpPost("organization-reports")]
|
||||
public async Task<OrganizationReport> AddOrganizationReport([FromBody] AddOrganizationReportRequest request)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops organization reports for an organization
|
||||
/// </summary>
|
||||
/// <param name="request">A single instance of DropOrganizationReportRequest</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization does not have any records</exception>
|
||||
[HttpDelete("organization-reports")]
|
||||
public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _dropOrganizationReportCommand.DropOrganizationReportAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets organization reports for an organization
|
||||
/// </summary>
|
||||
/// <param name="orgId">A valid Organization Id</param>
|
||||
/// <returns>An Enumerable of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpGet("organization-reports/{orgId}")]
|
||||
public async Task<IEnumerable<OrganizationReport>> GetOrganizationReports(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest organization report for an organization
|
||||
/// </summary>
|
||||
/// <param name="orgId">A valid Organization Id</param>
|
||||
/// <returns>A single instance of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpGet("organization-reports/latest/{orgId}")]
|
||||
public async Task<OrganizationReport> GetLatestOrganizationReport(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
public class MemberAccessDetailReportResponseModel
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public bool AccountRecoveryEnabled { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
|
||||
public MemberAccessDetailReportResponseModel(MemberAccessReportDetail reportDetail)
|
||||
{
|
||||
UserGuid = reportDetail.UserGuid;
|
||||
UserName = reportDetail.UserName;
|
||||
Email = reportDetail.Email;
|
||||
TwoFactorEnabled = reportDetail.TwoFactorEnabled;
|
||||
AccountRecoveryEnabled = reportDetail.AccountRecoveryEnabled;
|
||||
UsesKeyConnector = reportDetail.UsesKeyConnector;
|
||||
CollectionId = reportDetail.CollectionId;
|
||||
GroupId = reportDetail.GroupId;
|
||||
GroupName = reportDetail.GroupName;
|
||||
CollectionName = reportDetail.CollectionName;
|
||||
ReadOnly = reportDetail.ReadOnly;
|
||||
HidePasswords = reportDetail.HidePasswords;
|
||||
Manage = reportDetail.Manage;
|
||||
CipherIds = reportDetail.CipherIds;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Models.Data;
|
||||
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class MemberCipherDetailsResponseModel
|
||||
@ -15,12 +14,12 @@ public class MemberCipherDetailsResponseModel
|
||||
/// </summary>
|
||||
public IEnumerable<string> CipherIds { get; set; }
|
||||
|
||||
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||
public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail)
|
||||
{
|
||||
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||
this.UserName = memberAccessCipherDetails.UserName;
|
||||
this.Email = memberAccessCipherDetails.Email;
|
||||
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||
this.UserGuid = reportDetail.UserGuid;
|
||||
this.UserName = reportDetail.UserName;
|
||||
this.Email = reportDetail.Email;
|
||||
this.UsesKeyConnector = reportDetail.UsesKeyConnector;
|
||||
this.CipherIds = reportDetail.CipherIds;
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
using Bit.Core.Context;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.NotificationHub;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -20,14 +23,14 @@ namespace Bit.Api.Platform.Push;
|
||||
public class PushController : Controller
|
||||
{
|
||||
private readonly IPushRegistrationService _pushRegistrationService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly IPushRelayer _pushRelayer;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
|
||||
public PushController(
|
||||
IPushRegistrationService pushRegistrationService,
|
||||
IPushNotificationService pushNotificationService,
|
||||
IPushRelayer pushRelayer,
|
||||
IWebHostEnvironment environment,
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings)
|
||||
@ -35,7 +38,7 @@ public class PushController : Controller
|
||||
_currentContext = currentContext;
|
||||
_environment = environment;
|
||||
_pushRegistrationService = pushRegistrationService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_pushRelayer = pushRelayer;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
@ -74,31 +77,50 @@ public class PushController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
public async Task SendAsync([FromBody] PushSendRequestModel model)
|
||||
public async Task SendAsync([FromBody] PushSendRequestModel<JsonElement> model)
|
||||
{
|
||||
CheckUsage();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.InstallationId))
|
||||
NotificationTarget target;
|
||||
Guid targetId;
|
||||
|
||||
if (model.InstallationId.HasValue)
|
||||
{
|
||||
if (_currentContext.InstallationId!.Value.ToString() != model.InstallationId!)
|
||||
if (_currentContext.InstallationId!.Value != model.InstallationId.Value)
|
||||
{
|
||||
throw new BadRequestException("InstallationId does not match current context.");
|
||||
}
|
||||
|
||||
await _pushNotificationService.SendPayloadToInstallationAsync(
|
||||
_currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier),
|
||||
Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.Installation;
|
||||
targetId = _currentContext.InstallationId.Value;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserId))
|
||||
else if (model.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId),
|
||||
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.User;
|
||||
targetId = model.UserId.Value;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.OrganizationId))
|
||||
else if (model.OrganizationId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId),
|
||||
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.Organization;
|
||||
targetId = model.OrganizationId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new UnreachableException("Model validation should have prevented getting here.");
|
||||
}
|
||||
|
||||
var notification = new RelayedNotification
|
||||
{
|
||||
Type = model.Type,
|
||||
Target = target,
|
||||
TargetId = targetId,
|
||||
Payload = model.Payload,
|
||||
Identifier = model.Identifier,
|
||||
DeviceId = model.DeviceId,
|
||||
ClientType = model.ClientType,
|
||||
};
|
||||
|
||||
await _pushRelayer.RelayAsync(_currentContext.InstallationId.Value, notification);
|
||||
}
|
||||
|
||||
private string Prefix(string value)
|
||||
|
@ -7,7 +7,7 @@ public enum PolicyType : byte
|
||||
PasswordGenerator = 2,
|
||||
SingleOrg = 3,
|
||||
RequireSso = 4,
|
||||
PersonalOwnership = 5,
|
||||
OrganizationDataOwnership = 5,
|
||||
DisableSend = 6,
|
||||
SendOptions = 7,
|
||||
ResetPassword = 8,
|
||||
@ -35,7 +35,7 @@ public static class PolicyTypeExtensions
|
||||
PolicyType.PasswordGenerator => "Password generator",
|
||||
PolicyType.SingleOrg => "Single organization",
|
||||
PolicyType.RequireSso => "Require single sign-on authentication",
|
||||
PolicyType.PersonalOwnership => "Remove individual vault",
|
||||
PolicyType.OrganizationDataOwnership => "Enforce organization data ownership",
|
||||
PolicyType.DisableSend => "Remove Send",
|
||||
PolicyType.SendOptions => "Send options",
|
||||
PolicyType.ResetPassword => "Account recovery administration",
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public interface IIntegrationMessage
|
||||
{
|
@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationHandlerResult
|
||||
{
|
@ -3,7 +3,7 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationMessage : IIntegrationMessage
|
||||
{
|
@ -5,7 +5,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationTemplateContext(EventMessage eventMessage)
|
||||
{
|
@ -1,5 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string token);
|
@ -1,5 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string channelId);
|
@ -1,5 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string channelId, string token);
|
@ -1,5 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(string url);
|
@ -1,5 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetails(string url);
|
@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -28,6 +29,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
|
||||
public ConfirmOrganizationUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -41,7 +43,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
IPolicyService policyService,
|
||||
IDeviceRepository deviceRepository,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -55,10 +58,11 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
_deviceRepository = deviceRepository;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_featureService = featureService;
|
||||
_collectionRepository = collectionRepository;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
Guid confirmingUserId)
|
||||
Guid confirmingUserId, string defaultUserCollectionName = null)
|
||||
{
|
||||
var result = await ConfirmUsersAsync(
|
||||
organizationId,
|
||||
@ -75,6 +79,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
{
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
|
||||
|
||||
return orgUser;
|
||||
}
|
||||
|
||||
@ -213,4 +220,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
|
||||
.Select(d => d.Id.ToString());
|
||||
}
|
||||
|
||||
private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, OrganizationUser organizationUser, string defaultUserCollectionName)
|
||||
{
|
||||
// Create DefaultUserCollection type collection for the user if the OrganizationDataOwnership policy is enabled for the organization
|
||||
var requiresDefaultCollection = await OrganizationRequiresDefaultCollectionAsync(organizationId, organizationUser.UserId.Value, defaultUserCollectionName);
|
||||
if (requiresDefaultCollection)
|
||||
{
|
||||
await CreateDefaultCollectionAsync(organizationId, organizationUser.Id, defaultUserCollectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> OrganizationRequiresDefaultCollectionAsync(Guid organizationId, Guid userId, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var organizationDataOwnershipRequirement = await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId);
|
||||
return organizationDataOwnershipRequirement.RequiresDefaultCollection(organizationId);
|
||||
}
|
||||
|
||||
private async Task CreateDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
|
||||
{
|
||||
var collection = new Collection
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = defaultCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
};
|
||||
|
||||
var userAccess = new List<CollectionAccessSelection>
|
||||
{
|
||||
new CollectionAccessSelection
|
||||
{
|
||||
Id = organizationUserId,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true
|
||||
}
|
||||
};
|
||||
|
||||
await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess);
|
||||
}
|
||||
}
|
||||
|
@ -15,9 +15,10 @@ public interface IConfirmOrganizationUserCommand
|
||||
/// <param name="organizationUserId">The ID of the organization user to confirm.</param>
|
||||
/// <param name="key">The encrypted organization key for the user.</param>
|
||||
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
|
||||
/// <param name="defaultUserCollectionName">Optional encrypted collection name for creating a default collection.</param>
|
||||
/// <returns>The confirmed organization user.</returns>
|
||||
/// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception>
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Confirms multiple organization users who have accepted their invitations.
|
||||
|
@ -1,11 +1,12 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IUpdateOrganizationUserCommand
|
||||
{
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator(
|
||||
return invalidEnvironment.Map(request);
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
// Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing.
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||
|
||||
if (provider is not null)
|
||||
{
|
||||
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||
@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator(
|
||||
}
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
|
||||
public static class InviteUserPaymentValidation
|
||||
{
|
||||
|
@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
/// Update an organization user.
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">The modified organization user to save.</param>
|
||||
/// <param name="existingUserType">The current type (member role) of the user.</param>
|
||||
/// <param name="savingUserId">The userId of the currently logged in user who is making the change.</param>
|
||||
/// <param name="collectionAccess">The user's updated collection access. If set to null, this removes all collection access.</param>
|
||||
/// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
|
||||
/// <exception cref="BadRequestException"></exception>
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType,
|
||||
Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
|
||||
{
|
||||
// Avoid multiple enumeration
|
||||
@ -83,15 +85,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
|
||||
{
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId.Value);
|
||||
if (adminCount > 0)
|
||||
{
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
}
|
||||
await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization);
|
||||
|
||||
if (collectionAccessList.Count != 0)
|
||||
{
|
||||
@ -151,6 +145,40 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);
|
||||
}
|
||||
|
||||
private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization)
|
||||
{
|
||||
|
||||
if (organization.PlanType != PlanType.Free)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!updatedOrgUser.UserId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value);
|
||||
|
||||
var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner;
|
||||
|
||||
if (isCurrentAdminOrOwner && adminCount <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCurrentAdminOrOwner && adminCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
|
||||
private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
|
||||
ICollection<CollectionAccessSelection> collectionAccess)
|
||||
{
|
||||
|
@ -0,0 +1,130 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
|
||||
public record ResellerClientOrganizationSignUpResponse(
|
||||
Organization Organization,
|
||||
OrganizationUser OwnerOrganizationUser);
|
||||
|
||||
/// <summary>
|
||||
/// Command for signing up reseller client organizations in a pending state.
|
||||
/// </summary>
|
||||
public interface IResellerClientOrganizationSignUpCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign up a reseller client organization. The organization will be created in a pending state
|
||||
/// (disabled and with Pending status) and the owner will be invited via email. The organization
|
||||
/// will become active once the owner accepts the invitation.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to create.</param>
|
||||
/// <param name="ownerEmail">The email of the organization owner who will be invited.</param>
|
||||
/// <returns>A response containing the created pending organization and invited owner user.</returns>
|
||||
Task<ResellerClientOrganizationSignUpResponse> SignUpResellerClientAsync(
|
||||
Organization organization,
|
||||
string ownerEmail);
|
||||
}
|
||||
|
||||
public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizationSignUpCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||
private readonly IPaymentService _paymentService;
|
||||
|
||||
public ResellerClientOrganizationSignUpCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IEventService eventService,
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||
IPaymentService paymentService)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_eventService = eventService;
|
||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||
_paymentService = paymentService;
|
||||
}
|
||||
|
||||
public async Task<ResellerClientOrganizationSignUpResponse> SignUpResellerClientAsync(
|
||||
Organization organization,
|
||||
string ownerEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
var createdOrganization = await CreateOrganizationAsync(organization);
|
||||
var ownerOrganizationUser = await CreateAndInviteOwnerAsync(createdOrganization, ownerEmail);
|
||||
|
||||
await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
|
||||
|
||||
return new ResellerClientOrganizationSignUpResponse(organization, ownerOrganizationUser);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _paymentService.CancelAndRecoverChargesAsync(organization);
|
||||
|
||||
if (organization.Id != default)
|
||||
{
|
||||
// Deletes the organization and all related data, including its owner user
|
||||
await _organizationRepository.DeleteAsync(organization);
|
||||
await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Organization> CreateOrganizationAsync(Organization organization)
|
||||
{
|
||||
organization.Id = CoreHelpers.GenerateComb();
|
||||
organization.Enabled = false;
|
||||
organization.Status = OrganizationStatusType.Pending;
|
||||
|
||||
await _organizationRepository.CreateAsync(organization);
|
||||
await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30),
|
||||
Type = OrganizationApiKeyType.Default,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
});
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUser> CreateAndInviteOwnerAsync(Organization organization, string ownerEmail)
|
||||
{
|
||||
var ownerOrganizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = null,
|
||||
Email = ownerEmail,
|
||||
Key = null,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
};
|
||||
|
||||
await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
|
||||
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||
users: [ownerOrganizationUser],
|
||||
organization: organization,
|
||||
initOrganization: true));
|
||||
|
||||
return ownerOrganizationUser;
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the Organization Data Ownership policy state.
|
||||
/// </summary>
|
||||
public enum OrganizationDataOwnershipState
|
||||
{
|
||||
/// <summary>
|
||||
/// Organization Data Ownership is enforced- members are required to save items to an organization.
|
||||
/// </summary>
|
||||
Enabled = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Organization Data Ownership is not enforced- users can save items to their personal vault.
|
||||
/// </summary>
|
||||
Disabled = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy requirements for the Organization data ownership policy
|
||||
/// </summary>
|
||||
public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement
|
||||
{
|
||||
private readonly IEnumerable<Guid> _organizationIdsWithPolicyEnabled;
|
||||
|
||||
/// <param name="organizationDataOwnershipState">
|
||||
/// The organization data ownership state for the user.
|
||||
/// </param>
|
||||
/// <param name="organizationIdsWithPolicyEnabled">
|
||||
/// The collection of Organization IDs that have the Organization Data Ownership policy enabled.
|
||||
/// </param>
|
||||
public OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState organizationDataOwnershipState,
|
||||
IEnumerable<Guid> organizationIdsWithPolicyEnabled)
|
||||
{
|
||||
_organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? [];
|
||||
State = organizationDataOwnershipState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Organization data ownership policy state for the user.
|
||||
/// </summary>
|
||||
public OrganizationDataOwnershipState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the Organization Data Ownership policy is enforced in that organization.
|
||||
/// </summary>
|
||||
public bool RequiresDefaultCollection(Guid organizationId)
|
||||
{
|
||||
return _organizationIdsWithPolicyEnabled.Contains(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<OrganizationDataOwnershipPolicyRequirement>
|
||||
{
|
||||
public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership;
|
||||
|
||||
public override OrganizationDataOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||
{
|
||||
var organizationDataOwnershipState = policyDetails.Any()
|
||||
? OrganizationDataOwnershipState.Enabled
|
||||
: OrganizationDataOwnershipState.Disabled;
|
||||
var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet();
|
||||
|
||||
return new OrganizationDataOwnershipPolicyRequirement(
|
||||
organizationDataOwnershipState,
|
||||
organizationIdsWithPolicyEnabled);
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
|
||||
/// <summary>
|
||||
/// Policy requirements for the Disable Personal Ownership policy.
|
||||
/// </summary>
|
||||
public class PersonalOwnershipPolicyRequirement : IPolicyRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization.
|
||||
/// </summary>
|
||||
public bool DisablePersonalOwnership { get; init; }
|
||||
}
|
||||
|
||||
public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<PersonalOwnershipPolicyRequirement>
|
||||
{
|
||||
public override PolicyType PolicyType => PolicyType.PersonalOwnership;
|
||||
|
||||
public override PersonalOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||
{
|
||||
var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() };
|
||||
return result;
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, OrganizationDataOwnershipPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>();
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
@ -42,7 +41,6 @@ public interface IOrganizationService
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
|
@ -33,6 +33,13 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await _processor.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
_logger.LogError(
|
||||
@ -49,16 +56,4 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_processor.DisposeAsync().GetAwaiter().GetResult();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
@ -1,7 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
@ -0,0 +1,375 @@
|
||||
# Design goals
|
||||
|
||||
The main goal of event integrations is to easily enable adding new integrations over time without the need
|
||||
for a lot of custom work to expose events to a new integration. The ability of fan-out offered by AMQP
|
||||
(either in RabbitMQ or in Azure Service Bus) gives us a way to attach any number of new integrations to the
|
||||
existing event system without needing to add special handling. By adding a new listener to the existing
|
||||
pipeline, it gains an independent stream of events without the need for additional broadcast code.
|
||||
|
||||
We want to enable robust handling of failures and retries. By utilizing the two-tier approach
|
||||
([described below](#two-tier-exchange)), we build in support at the service level for retries. When we add
|
||||
new integrations, they can focus solely on the integration-specific logic and reporting status, with all the
|
||||
process of retries and delays managed by the messaging system.
|
||||
|
||||
Another goal is to not only support this functionality in the cloud version, but offer it as well to
|
||||
self-hosted instances. RabbitMQ provides a lightweight way for self-hosted instances to tie into the event system
|
||||
using the same robust architecture for integrations without the need for Azure Service Bus.
|
||||
|
||||
Finally, we want to offer organization admins flexibility and control over what events are significant, where
|
||||
to send events, and the data to be included in the message. The configuration architecture allows Organizations
|
||||
to customize details of a specific integration; see [Integrations and integration
|
||||
configurations](#integrations-and-integration-configurations) below for more details on the configuration piece.
|
||||
|
||||
# Architecture
|
||||
|
||||
The entry point for the event integrations is the `IEventWriteService`. By configuring the
|
||||
`EventIntegrationEventWriteService` as the `EventWriteService`, all events sent to the
|
||||
service are broadcast on the RabbitMQ or Azure Service Bus message exchange. To abstract away
|
||||
the specifics of publishing to a specific AMQP provider, an `IEventIntegrationPublisher`
|
||||
is injected into `EventIntegrationEventWriteService` to handle the publishing of events to the
|
||||
RabbitMQ or Azure Service Bus service.
|
||||
|
||||
## Two-tier exchange
|
||||
|
||||
When `EventIntegrationEventWriteService` publishes, it posts to the first tier of our two-tier
|
||||
approach to handling messages. Each tier is represented in the AMQP stack by a separate exchange
|
||||
(in RabbitMQ terminology) or topic (in Azure Service Bus).
|
||||
|
||||
``` mermaid
|
||||
flowchart TD
|
||||
B1[EventService]
|
||||
B2[EventIntegrationEventWriteService]
|
||||
B3[Event Exchange / Topic]
|
||||
B4[EventRepositoryHandler]
|
||||
B5[WebhookIntegrationHandler]
|
||||
B6[Events in Database / Azure Tables]
|
||||
B7[HTTP Server]
|
||||
B8[SlackIntegrationHandler]
|
||||
B9[Slack]
|
||||
B10[EventIntegrationHandler]
|
||||
B12[Integration Exchange / Topic]
|
||||
|
||||
B1 -->|IEventWriteService| B2 --> B3
|
||||
B3-->|EventListenerService| B4 --> B6
|
||||
B3-->|EventListenerService| B10
|
||||
B3-->|EventListenerService| B10
|
||||
B10 --> B12
|
||||
B12 -->|IntegrationListenerService| B5
|
||||
B12 -->|IntegrationListenerService| B8
|
||||
B5 -->|HTTP POST| B7
|
||||
B8 -->|HTTP POST| B9
|
||||
```
|
||||
|
||||
### Event tier
|
||||
|
||||
In the first tier, events are broadcast in a fan-out to a series of listeners. The message body
|
||||
is a JSON representation of an individual `EventMessage` or an array of `EventMessage`. Handlers at
|
||||
this level are responsible for handling each event or array of events. There are currently two handlers
|
||||
at this level:
|
||||
- `EventRepositoryHandler`
|
||||
- The `EventRepositoryHandler` is responsible for long term storage of events. It receives all events
|
||||
and stores them via an injected `IEventRepository` into the database.
|
||||
- This mirrors the behavior of when event integrations are turned off - cloud stores to Azure Tables
|
||||
and self-hosted is stored to the database.
|
||||
- `EventIntegrationHandler`
|
||||
- The `EventIntegrationHandler` is a generic class that is customized to each integration (via the
|
||||
configuration details of the integration) and is responsible for determining if there's a configuration
|
||||
for this event / organization / integration, fetching that configuration, and parsing the details of the
|
||||
event into a template string.
|
||||
- The `EventIntegrationHandler` uses the injected `IOrganizationIntegrationConfigurationRepository` to pull
|
||||
the specific set of configuration and template based on the event type, organization, and integration type.
|
||||
This configuration is what determines if an integration should be sent, what details are necessary for sending
|
||||
it, and the actual message to send.
|
||||
- The output of `EventIntegrationHandler` is a new `IntegrationMessage`, with the details of this
|
||||
the configuration necessary to interact with the integration and the message to send (with all the event
|
||||
details incorporated), published to the integration level of the message bus.
|
||||
|
||||
### Integration tier
|
||||
|
||||
At the integration level, messages are JSON representations of `IIntegrationMessage` - specifically they
|
||||
will be concrete types of the generic `IntegrationMessage<T>` where `<T>` is the configuration details of the
|
||||
specific integration for which they've been sent. These messages represent the details required for
|
||||
sending a specific event to a specific integration, including handling retries and delays.
|
||||
|
||||
Handlers at the integration level are tied directly to the integration (e.g. `SlackIntegrationHandler`,
|
||||
`WebhookIntegrationHandler`). These handlers take in `IntegrationMessage<T>` and output
|
||||
`IntegrationHandlerResult`, which tells the listener the outcome of the integration (e.g. success / fail,
|
||||
if it can be retried and any minimum delay that should occur). This makes them easy to unit test in isolation
|
||||
without any of the concerns of AMQP or messaging.
|
||||
|
||||
The listeners at this level are responsible for firing off the handler when a new message comes in and then
|
||||
taking the correct action based on the result. Successful results simply acknowledge the message and resolve.
|
||||
Failures will either be sent to the dead letter queue (DLQ) or re-published for retry after the correct amount of delay.
|
||||
|
||||
### Retries
|
||||
|
||||
One of the goals of introducing the integration level is to simplify and enable the process of multiple retries
|
||||
for a specific event integration. For instance, if a service is temporarily down, we don't want one of our handlers
|
||||
blocking the rest of the queue while it waits to retry. In addition, we don't want to retry _all_ integrations for a
|
||||
specific event if only one integration fails nor do we want to re-lookup the configuration details. By splitting
|
||||
out the `IntegrationMessage<T>` with the configuration, message, and details around retries, we can process each
|
||||
event / integration individually and retry easily.
|
||||
|
||||
When the `IntegrationHandlerResult.Success` is set to `false` (indicating that the integration attempt failed) the
|
||||
`Retryable` flag tells the listener whether this failure is temporary or final. If the `Retryable` is `false`, then
|
||||
the message is immediately sent to the DLQ. If it is `true`, the listener uses the `ApplyRetry(DateTime)` method
|
||||
in `IntegrationMessage` which handles both incrementing the `RetryCount` and updating the `DelayUntilDate` using
|
||||
the provided DateTime, but also adding exponential backoff (based on `RetryCount`) and jitter. The listener compares
|
||||
the `RetryCount` in the `IntegrationMessage` to see if it's over the `MaxRetries` defined in Global Settings. If it
|
||||
is over the `MaxRetries`, the message is sent to the DLQ. Otherwise, it is scheduled for retry.
|
||||
|
||||
``` mermaid
|
||||
flowchart TD
|
||||
A[Success == false] --> B{Retryable?}
|
||||
B -- No --> C[Send to Dead Letter Queue DLQ]
|
||||
B -- Yes --> D[Check RetryCount vs MaxRetries]
|
||||
D -->|RetryCount >= MaxRetries| E[Send to Dead Letter Queue DLQ]
|
||||
D -->|RetryCount < MaxRetries| F[Schedule for Retry]
|
||||
```
|
||||
|
||||
Azure Service Bus supports scheduling messages as part of its core functionality. Retries are scheduled to a specific
|
||||
time and then ASB holds the message and publishes it at the correct time.
|
||||
|
||||
#### RabbitMQ retry options
|
||||
|
||||
For RabbitMQ (which will be used by self-host only), we have two different options. The `useDelayPlugin` flag in
|
||||
`GlobalSettings.RabbitMqSettings` determines which one is used. If it is set to `true`, we use the delay plugin. It
|
||||
defaults to `false` which indicates we should use retry queues with a timing check.
|
||||
|
||||
1. Delay plugin
|
||||
- [Delay plugin GitHub repo](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange)
|
||||
- This plugin enables a delayed message exchange in RabbitMQ that supports delaying a message for an amount
|
||||
of time specified in a special header.
|
||||
- This allows us to forego using any retry queues and rely instead on the delay exchange. When a message is
|
||||
marked with the header it gets published to the exchange and the exchange handles all the functionality of
|
||||
holding it until the appropriate time (similar to ASB's built-in support).
|
||||
- The plugin must be setup and enabled before turning this option on (which is why it defaults to off).
|
||||
|
||||
2. Retry queues + timing check
|
||||
- If the delay plugin setting is off, we push the message to a retry queue which has a fixed amount of time before
|
||||
it gets re-published back to the main queue.
|
||||
- When a message comes off the queue, we check to see if the `DelayUntilDate` has already passed.
|
||||
- If it has passed, we then handle the integration normally and retry the request.
|
||||
- If it is still in the future, we put the message back on the retry queue for an additional wait.
|
||||
- While this does use extra processing, it gives us better support for honoring the delays even if the delay plugin
|
||||
isn't enabled. Since this solution is only intended for self-host, it should be a pretty minimal impact with short
|
||||
delays and a small number of retries.
|
||||
|
||||
## Listener / Handler pattern
|
||||
|
||||
To make it easy to support multiple AMQP services (RabbitMQ and Azure Service Bus), the act
|
||||
of listening to the stream of messages is decoupled from the act of responding to a message.
|
||||
|
||||
### Listeners
|
||||
|
||||
- Listeners handle the details of the communication platform (i.e. RabbitMQ and Azure Service Bus).
|
||||
- There is one listener for each platform (RabbitMQ / ASB) for each of the two levels - i.e. one event listener
|
||||
and one integration listener.
|
||||
- Perform all the aspects of setup / teardown, subscription, message acknowledgement, etc. for the messaging platform,
|
||||
but do not directly process any events themselves. Instead, they delegate to the handler with which they
|
||||
are configured.
|
||||
- Multiple instances can be configured to run independently, each with its own handler and
|
||||
subscription / queue.
|
||||
|
||||
### Handlers
|
||||
|
||||
- One handler per queue / subscription (e.g. per integration at the integration level).
|
||||
- Completely isolated from and know nothing of the messaging platform in use. This allows them to be
|
||||
freely reused across different communication platforms.
|
||||
- Perform all aspects of handling an event.
|
||||
- Allows them to be highly testable as they are isolated and decoupled from the more complicated
|
||||
aspects of messaging.
|
||||
|
||||
This combination allows for a configuration inside of `ServiceCollectionExtensions.cs` that pairs
|
||||
instances of the listener service for the currently running messaging platform with any number of
|
||||
handlers. It also allows for quick development of new handlers as they are focused only on the
|
||||
task of handling a specific event.
|
||||
|
||||
## Publishers and Services
|
||||
|
||||
Listeners (and `EventIntegrationHandler`) interact with the messaging system via the `IEventPublisher` interface,
|
||||
which is backed by a RabbitMQ and ASB specific service. By placing most of the messaging platform details in the
|
||||
service layer, we are able to handle common things like configuring the connection, binding or creating a specific
|
||||
queue, etc. in one place. The `IRabbitMqService` and `IAzureServiceBusService` implement the `IEventPublisher`
|
||||
interface and therefore can also handle directly all the message publishing functionality.
|
||||
|
||||
## Integrations and integration configurations
|
||||
|
||||
Organizations can configure integration configurations to send events to different endpoints -- each
|
||||
handler maps to a specific integration and checks for the configuration when it receives an event.
|
||||
Currently, there are integrations / handlers for Slack and webhooks (as mentioned above).
|
||||
|
||||
### `OrganizationIntegration`
|
||||
|
||||
- The top-level object that enables a specific integration for the organization.
|
||||
- Includes any properties that apply to the entire integration across all events.
|
||||
- For Slack, it consists of the token: `{ "token": "xoxb-token-from-slack" }`
|
||||
- For webhooks, it is `null`. However, even though there is no configuration, an organization must
|
||||
have a webhook `OrganizationIntegration` to enable configuration via `OrganizationIntegrationConfiguration`.
|
||||
|
||||
### `OrganizationIntegrationConfiguration`
|
||||
|
||||
- This contains the configurations specific to each `EventType` for the integration.
|
||||
- `Configuration` contains the event-specific configuration.
|
||||
- For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }`
|
||||
- For Webhook, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }`
|
||||
- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
|
||||
- The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
|
||||
- The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
|
||||
the provided `EventMessage`.
|
||||
- The template does not enforce any structure — it could be a freeform text message to send via Slack, or a
|
||||
JSON body to send via webhook; it is simply stored and used as a string for the most flexibility.
|
||||
|
||||
### `OrganizationIntegrationConfigurationDetails`
|
||||
|
||||
- This is the combination of both the `OrganizationIntegration` and `OrganizationIntegrationConfiguration` into
|
||||
a single object. The combined contents tell the integration's handler all the details needed to send to an
|
||||
external service.
|
||||
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
|
||||
the database to determine what to publish at the integration level.
|
||||
|
||||
# Building a new integration
|
||||
|
||||
These are all the pieces required in the process of building out a new integration. For
|
||||
clarity in naming, these assume a new integration called "Example".
|
||||
|
||||
## IntegrationType
|
||||
|
||||
Add a new type to `IntegrationType` for the new integration.
|
||||
|
||||
## Configuration Models
|
||||
|
||||
The configuration models are the classes that will determine what is stored in the database for
|
||||
`OrganizationIntegration` and `OrganizationIntegrationConfiguration`. The `Configuration` columns are the
|
||||
serialized version of the corresponding objects and represent the coonfiguration details for this integration
|
||||
and event type.
|
||||
|
||||
1. `ExampleIntegration`
|
||||
- Configuration details for the whole integration (e.g. a token in Slack).
|
||||
- Applies to every event type configuration defined for this integration.
|
||||
- Maps to the JSON structure stored in `Configuration` in ``OrganizationIntegration`.
|
||||
2. `ExampleIntegrationConfiguration`
|
||||
- Configuration details that could change from event to event (e.g. channelId in Slack).
|
||||
- Maps to the JSON structure stored in `Configuration` in `OrganizationIntegrationConfiguration`.
|
||||
3. `ExampleIntegrationConfigurationDetails`
|
||||
- Combined configuration of both Integration _and_ IntegrationConfiguration.
|
||||
- This will be the deserialized version of the `MergedConfiguration` in
|
||||
`OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
## Request Models
|
||||
|
||||
1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
|
||||
2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
|
||||
|
||||
## Integration Handler
|
||||
|
||||
e.g. `ExampleIntegrationHandler`
|
||||
- This is where the actual code will go to perform the integration (i.e. send an HTTP request, etc.).
|
||||
- Handlers receive an `IntegrationMessage<T>` where `<T>` is the `ExampleIntegrationConfigurationDetails`
|
||||
defined above. This has the Configuration as well as the rendered template message to be sent.
|
||||
- Handlers return an `IntegrationHandlerResult` with details about if the request - success / failure,
|
||||
if it can be retried, when it should be delayed until, etc.
|
||||
- The scope of the handler is simply to do the integration and report the result.
|
||||
Everything else (such as how many times to retry, when to retry, what to do with failures)
|
||||
is done in the Listener.
|
||||
|
||||
## GlobalSettings
|
||||
|
||||
### RabbitMQ
|
||||
Add the queue names for the integration. These are typically set with a default value so
|
||||
that they will be created when first accessed in code by RabbitMQ.
|
||||
|
||||
1. `ExampleEventQueueName`
|
||||
2. `ExampleIntegrationQueueName`
|
||||
3. `ExampleIntegrationRetryQueueName`
|
||||
|
||||
### Azure Service Bus
|
||||
Add the subscription names to use for ASB for this integration. Similar to RabbitMQ a
|
||||
default value is provided so that we don't require configuring it in secrets but allow
|
||||
it to be overridden. **However**, unlike RabbitMQ these subscriptions must exist prior
|
||||
to the code accessing them. They will not be created on the fly. See [Deploying a new
|
||||
integration](#deploying-a-new-integration) below
|
||||
|
||||
1. `ExmpleEventSubscriptionName`
|
||||
2. `ExmpleIntegrationSubscriptionName`
|
||||
|
||||
#### Service Bus Emulator, local config
|
||||
In order to create ASB resources locally, we need to also update the `servicebusemulator_config.json` file
|
||||
to include any new subscriptions.
|
||||
- Under the existing event topic (`event-logging`) add a subscription for the event level for this
|
||||
new integration (`events-example-subscription`).
|
||||
- Under the existing integration topic (`event-integrations`) add a new subscription for the integration
|
||||
level messages (`integration-example-subscription`).
|
||||
- Copy the correlation filter from the other integration level subscriptions. It should filter based on
|
||||
the `IntegrationType.ToRoutingKey`, or in this example `example`.
|
||||
|
||||
These names added here are what must match the values provided in the secrets or the defaults provided
|
||||
in Global Settings. This must be in place (and the local ASB emulator restarted) before you can use any
|
||||
code locally that accesses ASB resources.
|
||||
|
||||
## ServiceCollectionExtensions
|
||||
In our `ServiceCollectionExtensions`, we pull all the above pieces together to start listeners on each message
|
||||
tier with handlers to process the integration. There are a number of helper methods in here to make this simple
|
||||
to add a new integration - one call per platform.
|
||||
|
||||
Also note that if an integration needs a custom singleton / service defined, the add listeners method is a
|
||||
good place to set that up. For instance, `SlackIntegrationHandler` needs a `SlackService`, so the singleton
|
||||
declaration is right above the add integration method for slack. Same thing for webhooks when it comes to
|
||||
defining a custom HttpClient by name.
|
||||
|
||||
1. In `AddRabbitMqListeners` add the integration:
|
||||
``` csharp
|
||||
services.AddRabbitMqIntegration<ExampleIntegrationConfigurationDetails, ExampleIntegrationHandler>(
|
||||
globalSettings.EventLogging.RabbitMq.ExampleEventsQueueName,
|
||||
globalSettings.EventLogging.RabbitMq.ExampleIntegrationQueueName,
|
||||
globalSettings.EventLogging.RabbitMq.ExampleIntegrationRetryQueueName,
|
||||
globalSettings.EventLogging.RabbitMq.MaxRetries,
|
||||
IntegrationType.Example);
|
||||
```
|
||||
|
||||
2. In `AddAzureServiceBusListeners` add the integration:
|
||||
``` csharp
|
||||
services.AddAzureServiceBusIntegration<ExampleIntegrationConfigurationDetails, ExampleIntegrationHandler>(
|
||||
eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleEventSubscriptionName,
|
||||
integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleIntegrationSubscriptionName,
|
||||
integrationType: IntegrationType.Example,
|
||||
globalSettings: globalSettings);
|
||||
```
|
||||
|
||||
# Deploying a new integration
|
||||
|
||||
## RabbitMQ
|
||||
|
||||
RabbitMQ dynamically creates queues and exchanges when they are first accessed in code.
|
||||
Therefore, there is no need to manually create queues when deploying a new integration.
|
||||
They can be created and configured ahead of time, but it's not required. Note that once
|
||||
they are created, if any configurations need to be changed, the queue or exchange must be
|
||||
deleted and recreated.
|
||||
|
||||
## Azure Service Bus
|
||||
|
||||
Unlike RabbitMQ, ASB resources **must** be allocated before the code accesses them and
|
||||
will not be created on the fly. This means that any subscriptions needed for a new
|
||||
integration must be created in ASB before that code is deployed.
|
||||
|
||||
The two subscriptions created above in Global Settings and `servicebusemulator_config.json`
|
||||
need to be created in the Azure portal or CLI for the environment before deploying the
|
||||
code.
|
||||
|
||||
1. `ExmpleEventSubscriptionName`
|
||||
- This subscription is a fan-out subscription from the main event topic.
|
||||
- As such, it will start receiving all the events as soon as it is declared.
|
||||
- This can create a backlog before the integration-specific handler is declared and deployed.
|
||||
- One strategy to avoid this is to create the subscription with a false filter (e.g. `1 = 0`).
|
||||
- This will create the subscription, but the filter will ensure that no messages
|
||||
actually land in the subscription.
|
||||
- Code can be deployed that references the subscription, because the subscription
|
||||
legitimately exists (it is simply empty).
|
||||
- When the code is in place, and we're ready to start receiving messages on the new
|
||||
integration, we simply remove the filter to return the subscription to receiving
|
||||
all messages via fan-out.
|
||||
2. `ExmpleIntegrationSubscriptionName`
|
||||
- This subscription must be created before the new integration code can be deployed.
|
||||
- However, it is not fan-out, but rather a filter based on the `IntegrationType.ToRoutingKey`.
|
||||
- Therefore, it won't start receiving messages until organizations have active configurations.
|
||||
This means there's no risk of building up a backlog by declaring it ahead of time.
|
@ -2,7 +2,7 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
@ -20,6 +20,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
|
||||
string routingKey,
|
||||
@ -27,7 +28,8 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
string retryQueueName,
|
||||
int maxRetries,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILogger<RabbitMqIntegrationListenerService> logger)
|
||||
ILogger<RabbitMqIntegrationListenerService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_handler = handler;
|
||||
_routingKey = routingKey;
|
||||
@ -35,6 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
_queueName = queueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_maxRetries = maxRetries;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
}
|
||||
@ -74,7 +77,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
|
||||
if (integrationMessage is not null &&
|
||||
integrationMessage.DelayUntilDate.HasValue &&
|
||||
integrationMessage.DelayUntilDate.Value > DateTime.UtcNow)
|
||||
integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
|
||||
{
|
||||
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
@ -1,7 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
@ -3,13 +3,15 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
public class WebhookIntegrationHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TimeProvider timeProvider)
|
||||
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
@ -39,7 +41,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
if (int.TryParse(value, out var seconds))
|
||||
{
|
||||
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
|
||||
result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds);
|
||||
result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
|
||||
}
|
||||
else if (DateTimeOffset.TryParseExact(value,
|
||||
"r", // "r" is the round-trip format: RFC1123
|
@ -1,5 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -1705,27 +1704,4 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
|
||||
{
|
||||
organization.Id = CoreHelpers.GenerateComb();
|
||||
organization.Enabled = false;
|
||||
organization.Status = OrganizationStatusType.Pending;
|
||||
|
||||
await SignUpAsync(organization, default, null, null, true);
|
||||
|
||||
var ownerOrganizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = null,
|
||||
Email = ownerEmail,
|
||||
Key = null,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
};
|
||||
await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
|
||||
|
||||
await SendInviteAsync(ownerOrganizationUser, organization, true);
|
||||
await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
|
||||
}
|
||||
}
|
||||
|
@ -38,7 +38,6 @@ public static class UserServiceCollectionExtensions
|
||||
|
||||
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
|
||||
{
|
||||
services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>();
|
||||
services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>();
|
||||
}
|
||||
|
||||
|
@ -426,6 +426,19 @@ public class OrganizationBillingService(
|
||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||
};
|
||||
|
||||
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
|
||||
if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) &&
|
||||
!customer.Metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
{
|
||||
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
|
||||
{
|
||||
EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions
|
||||
{
|
||||
MissingPaymentMethod = "cancel"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
|
@ -107,11 +107,11 @@ public static class FeatureFlagKeys
|
||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
|
||||
public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript";
|
||||
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
|
||||
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
||||
|
||||
/* Auth Team */
|
||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||
@ -138,6 +138,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string InlineMenuTotp = "inline-menu-totp";
|
||||
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
|
||||
|
||||
/* Billing Team */
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
@ -181,6 +182,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
|
||||
public const string MobileErrorReporting = "mobile-error-reporting";
|
||||
public const string AndroidChromeAutofill = "android-chrome-autofill";
|
||||
public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
|
||||
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
||||
public const string AppIntents = "app-intents";
|
||||
|
||||
@ -205,6 +207,8 @@ public static class FeatureFlagKeys
|
||||
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
||||
public const string PhishingDetection = "phishing-detection";
|
||||
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
|
||||
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
|
||||
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
20
src/Core/Dirt/Entities/OrganizationApplication.cs
Normal file
20
src/Core/Dirt/Entities/OrganizationApplication.cs
Normal file
@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class OrganizationApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Applications { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
20
src/Core/Dirt/Entities/OrganizationReport.cs
Normal file
20
src/Core/Dirt/Entities/OrganizationReport.cs
Normal file
@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class OrganizationReport : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string ReportData { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.Entities;
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
namespace Bit.Core.Dirt.Models.Data;
|
||||
|
||||
public class MemberAccessDetails
|
||||
{
|
19
src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs
Normal file
19
src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
public class MemberAccessReportDetail
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public bool AccountRecoveryEnabled { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
}
|
19
src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs
Normal file
19
src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
public class OrganizationMemberBaseDetail
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string TwoFactorProviders { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public string ResetPasswordKey { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
public Guid CipherId { get; set; }
|
||||
}
|
10
src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs
Normal file
10
src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
public class RiskInsightsReportDetail
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public IEnumerable<string> CipherIds { get; set; }
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class AddOrganizationReportCommand : IAddOrganizationReportCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepo;
|
||||
private readonly IOrganizationReportRepository _organizationReportRepo;
|
||||
private ILogger<AddOrganizationReportCommand> _logger;
|
||||
|
||||
public AddOrganizationReportCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationReportRepository organizationReportRepository,
|
||||
ILogger<AddOrganizationReportCommand> logger)
|
||||
{
|
||||
_organizationRepo = organizationRepository;
|
||||
_organizationReportRepo = organizationReportRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OrganizationReport> AddOrganizationReportAsync(AddOrganizationReportRequest request)
|
||||
{
|
||||
_logger.LogInformation("Adding organization report for organization {organizationId}", request.OrganizationId);
|
||||
|
||||
var (isValid, errorMessage) = await ValidateRequestAsync(request);
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogInformation("Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage);
|
||||
throw new BadRequestException(errorMessage);
|
||||
}
|
||||
|
||||
var organizationReport = new OrganizationReport
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
ReportData = request.ReportData,
|
||||
Date = request.Date == default ? DateTime.UtcNow : request.Date,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
organizationReport.SetNewId();
|
||||
|
||||
var data = await _organizationReportRepo.CreateAsync(organizationReport);
|
||||
|
||||
_logger.LogInformation("Successfully added organization report for organization {organizationId}, {organizationReportId}",
|
||||
request.OrganizationId, data.Id);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(
|
||||
AddOrganizationReportRequest request)
|
||||
{
|
||||
// verify that the organization exists
|
||||
var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
return (false, "Invalid Organization");
|
||||
}
|
||||
|
||||
// ensure that we have report data
|
||||
if (string.IsNullOrWhiteSpace(request.ReportData))
|
||||
{
|
||||
return (false, "Report Data is required");
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
|
@ -0,0 +1,45 @@
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class DropOrganizationReportCommand : IDropOrganizationReportCommand
|
||||
{
|
||||
private IOrganizationReportRepository _organizationReportRepo;
|
||||
private ILogger<DropOrganizationReportCommand> _logger;
|
||||
|
||||
public DropOrganizationReportCommand(
|
||||
IOrganizationReportRepository organizationReportRepository,
|
||||
ILogger<DropOrganizationReportCommand> logger)
|
||||
{
|
||||
_organizationReportRepo = organizationReportRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task DropOrganizationReportAsync(DropOrganizationReportRequest request)
|
||||
{
|
||||
_logger.LogInformation("Dropping organization report for organization {organizationId}",
|
||||
request.OrganizationId);
|
||||
|
||||
var data = await _organizationReportRepo.GetByOrganizationIdAsync(request.OrganizationId);
|
||||
if (data == null || data.Count() == 0)
|
||||
{
|
||||
_logger.LogInformation("No organization reports found for organization {organizationId}", request.OrganizationId);
|
||||
throw new BadRequestException("No data found.");
|
||||
}
|
||||
|
||||
data
|
||||
.Where(_ => request.OrganizationReportIds.Contains(_.Id))
|
||||
.ToList()
|
||||
.ForEach(async reportId =>
|
||||
{
|
||||
_logger.LogInformation("Dropping organization report {organizationReportId} for organization {organizationId}",
|
||||
reportId, request.OrganizationId);
|
||||
|
||||
await _organizationReportRepo.DeleteAsync(reportId);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
@ -0,0 +1,43 @@
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class GetOrganizationReportQuery : IGetOrganizationReportQuery
|
||||
{
|
||||
private IOrganizationReportRepository _organizationReportRepo;
|
||||
private ILogger<GetOrganizationReportQuery> _logger;
|
||||
|
||||
public GetOrganizationReportQuery(
|
||||
IOrganizationReportRepository organizationReportRepo,
|
||||
ILogger<GetOrganizationReportQuery> logger)
|
||||
{
|
||||
_organizationReportRepo = organizationReportRepo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrganizationReport>> GetOrganizationReportAsync(Guid organizationId)
|
||||
{
|
||||
if (organizationId == Guid.Empty)
|
||||
{
|
||||
throw new BadRequestException("OrganizationId is required.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching organization reports for organization {organizationId}", organizationId);
|
||||
return await _organizationReportRepo.GetByOrganizationIdAsync(organizationId);
|
||||
}
|
||||
|
||||
public async Task<OrganizationReport> GetLatestOrganizationReportAsync(Guid organizationId)
|
||||
{
|
||||
if (organizationId == Guid.Empty)
|
||||
{
|
||||
throw new BadRequestException("OrganizationId is required.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching latest organization report for organization {organizationId}", organizationId);
|
||||
return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId);
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
@ -0,0 +1,10 @@
|
||||
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IAddOrganizationReportCommand
|
||||
{
|
||||
Task<OrganizationReport> AddOrganizationReportAsync(AddOrganizationReportRequest request);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
@ -0,0 +1,9 @@
|
||||
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IDropOrganizationReportCommand
|
||||
{
|
||||
Task DropOrganizationReportAsync(DropOrganizationReportRequest request);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
using Bit.Core.Dirt.Entities;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IGetOrganizationReportQuery
|
||||
{
|
||||
Task<IEnumerable<OrganizationReport>> GetOrganizationReportAsync(Guid organizationId);
|
||||
Task<OrganizationReport> GetLatestOrganizationReportAsync(Guid organizationId);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
|
@ -1,206 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Queries;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
||||
{
|
||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public MemberAccessCipherDetailsQuery(
|
||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||
IGroupRepository groupRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
||||
)
|
||||
{
|
||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||
_groupRepository = groupRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||
{
|
||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
||||
new OrganizationUserUserDetailsQueryRequest
|
||||
{
|
||||
OrganizationId = request.OrganizationId,
|
||||
IncludeCollections = true,
|
||||
IncludeGroups = true
|
||||
});
|
||||
|
||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
|
||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
|
||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
|
||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||
|
||||
var memberAccessCipherDetails = GenerateAccessDataParallel(
|
||||
orgGroups,
|
||||
orgCollectionsWithAccess,
|
||||
orgItems,
|
||||
organizationUsersTwoFactorEnabled,
|
||||
orgAbility);
|
||||
|
||||
return memberAccessCipherDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a report for all members of an organization. Containing summary information
|
||||
/// such as item, collection, and group counts. Including the cipherIds a member is assigned.
|
||||
/// Child collection includes detailed information on the user and group collections along
|
||||
/// with their permissions.
|
||||
/// </summary>
|
||||
/// <param name="orgGroups">Organization groups collection</param>
|
||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
||||
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
|
||||
private IEnumerable<MemberAccessCipherDetails> GenerateAccessDataParallel(
|
||||
ICollection<Group> orgGroups,
|
||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
||||
OrganizationAbility orgAbility)
|
||||
{
|
||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList();
|
||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
||||
var collectionItems = orgItems
|
||||
.SelectMany(x => x.CollectionIds,
|
||||
(cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
|
||||
.GroupBy(y => y.CollectionId,
|
||||
(key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
|
||||
var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList());
|
||||
|
||||
var memberAccessCipherDetails = new ConcurrentBag<MemberAccessCipherDetails>();
|
||||
|
||||
Parallel.ForEach(orgUsers, user =>
|
||||
{
|
||||
var groupAccessDetails = new List<MemberAccessDetails>();
|
||||
var userCollectionAccessDetails = new List<MemberAccessDetails>();
|
||||
|
||||
foreach (var tCollect in orgCollectionsWithAccess)
|
||||
{
|
||||
if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items))
|
||||
{
|
||||
var itemCounts = items.Count;
|
||||
|
||||
if (tCollect.Item2.Groups.Any())
|
||||
{
|
||||
var groupDetails = tCollect.Item2.Groups
|
||||
.Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id))
|
||||
.Select(x => new MemberAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
GroupId = x.Id,
|
||||
GroupName = groupNameDictionary[x.Id],
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
CollectionCipherIds = items
|
||||
});
|
||||
|
||||
groupAccessDetails.AddRange(groupDetails);
|
||||
}
|
||||
|
||||
if (tCollect.Item2.Users.Any())
|
||||
{
|
||||
var userCollectionDetails = tCollect.Item2.Users
|
||||
.Where(tCollectUser => tCollectUser.Id == user.Id)
|
||||
.Select(x => new MemberAccessDetails
|
||||
{
|
||||
CollectionId = tCollect.Item1.Id,
|
||||
CollectionName = tCollect.Item1.Name,
|
||||
ReadOnly = x.ReadOnly,
|
||||
HidePasswords = x.HidePasswords,
|
||||
Manage = x.Manage,
|
||||
ItemCount = itemCounts,
|
||||
CollectionCipherIds = items
|
||||
});
|
||||
|
||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var report = new MemberAccessCipherDetails
|
||||
{
|
||||
UserName = user.Name,
|
||||
Email = user.Email,
|
||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||
UserGuid = user.Id,
|
||||
UsesKeyConnector = user.UsesKeyConnector
|
||||
};
|
||||
|
||||
var userAccessDetails = new List<MemberAccessDetails>();
|
||||
if (user.Groups.Any())
|
||||
{
|
||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
||||
userAccessDetails.AddRange(userGroups);
|
||||
}
|
||||
|
||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
||||
if (groupsWithoutCollections.Any())
|
||||
{
|
||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
|
||||
{
|
||||
GroupId = x,
|
||||
GroupName = groupNameDictionary[x],
|
||||
ItemCount = 0
|
||||
});
|
||||
userAccessDetails.AddRange(emptyGroups);
|
||||
}
|
||||
|
||||
if (user.Collections.Any())
|
||||
{
|
||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
||||
userAccessDetails.AddRange(userCollections);
|
||||
}
|
||||
report.AccessDetails = userAccessDetails;
|
||||
|
||||
var userCiphers = report.AccessDetails
|
||||
.Where(x => x.ItemCount > 0)
|
||||
.SelectMany(y => y.CollectionCipherIds)
|
||||
.Distinct();
|
||||
report.CipherIds = userCiphers;
|
||||
report.TotalItemCount = userCiphers.Count();
|
||||
|
||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
||||
report.CollectionsCount = distinctItems.Count();
|
||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
||||
|
||||
memberAccessCipherDetails.Add(report);
|
||||
});
|
||||
|
||||
return memberAccessCipherDetails;
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class MemberAccessReportQuery(
|
||||
IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery
|
||||
{
|
||||
public async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(
|
||||
MemberAccessReportRequest request)
|
||||
{
|
||||
var baseDetails =
|
||||
await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
|
||||
request.OrganizationId);
|
||||
|
||||
var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct();
|
||||
var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
||||
|
||||
var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
|
||||
|
||||
var accessDetails = baseDetails
|
||||
.GroupBy(b => new
|
||||
{
|
||||
b.UserGuid,
|
||||
b.UserName,
|
||||
b.Email,
|
||||
b.TwoFactorProviders,
|
||||
b.ResetPasswordKey,
|
||||
b.UsesKeyConnector,
|
||||
b.GroupId,
|
||||
b.GroupName,
|
||||
b.CollectionId,
|
||||
b.CollectionName,
|
||||
b.ReadOnly,
|
||||
b.HidePasswords,
|
||||
b.Manage
|
||||
})
|
||||
.Select(g => new MemberAccessReportDetail
|
||||
{
|
||||
UserGuid = g.Key.UserGuid,
|
||||
UserName = g.Key.UserName,
|
||||
Email = g.Key.Email,
|
||||
TwoFactorEnabled = orgUsersTwoFactorEnabled.FirstOrDefault(x => x.userId == g.Key.UserGuid).twoFactorIsEnabled,
|
||||
AccountRecoveryEnabled = !string.IsNullOrWhiteSpace(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword,
|
||||
UsesKeyConnector = g.Key.UsesKeyConnector,
|
||||
GroupId = g.Key.GroupId,
|
||||
GroupName = g.Key.GroupName,
|
||||
CollectionId = g.Key.CollectionId,
|
||||
CollectionName = g.Key.CollectionName,
|
||||
ReadOnly = g.Key.ReadOnly,
|
||||
HidePasswords = g.Key.HidePasswords,
|
||||
Manage = g.Key.Manage,
|
||||
CipherIds = g.Select(c => c.CipherId)
|
||||
});
|
||||
|
||||
return accessDetails;
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
|
||||
public interface IMemberAccessCipherDetailsQuery
|
||||
public interface IMemberAccessReportQuery
|
||||
{
|
||||
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
|
||||
Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(MemberAccessReportRequest request);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
|
||||
public interface IRiskInsightsReportQuery
|
||||
{
|
||||
Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(RiskInsightsReportRequest request);
|
||||
}
|
@ -8,9 +8,13 @@ public static class ReportingServiceCollectionExtensions
|
||||
{
|
||||
public static void AddReportingServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>();
|
||||
services.AddScoped<IRiskInsightsReportQuery, RiskInsightsReportQuery>();
|
||||
services.AddScoped<IMemberAccessReportQuery, MemberAccessReportQuery>();
|
||||
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
|
||||
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
|
||||
services.AddScoped<IDropPasswordHealthReportApplicationCommand, DropPasswordHealthReportApplicationCommand>();
|
||||
services.AddScoped<IAddOrganizationReportCommand, AddOrganizationReportCommand>();
|
||||
services.AddScoped<IDropOrganizationReportCommand, DropOrganizationReportCommand>();
|
||||
services.AddScoped<IGetOrganizationReportQuery, GetOrganizationReportQuery>();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class AddOrganizationReportRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string ReportData { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class DropOrganizationReportRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
public IEnumerable<Guid> OrganizationReportIds { get; set; }
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class MemberAccessCipherDetailsRequest
|
||||
public class MemberAccessReportRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class RiskInsightsReportRequest
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class RiskInsightsReportQuery : IRiskInsightsReportQuery
|
||||
{
|
||||
private readonly IOrganizationMemberBaseDetailRepository _organizationMemberBaseDetailRepository;
|
||||
|
||||
public RiskInsightsReportQuery(IOrganizationMemberBaseDetailRepository repository)
|
||||
{
|
||||
_organizationMemberBaseDetailRepository = repository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(
|
||||
RiskInsightsReportRequest request)
|
||||
{
|
||||
var baseDetails =
|
||||
await _organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
|
||||
request.OrganizationId);
|
||||
|
||||
var insightsDetails = baseDetails
|
||||
.GroupBy(b => new { b.UserGuid, b.UserName, b.Email, b.UsesKeyConnector })
|
||||
.Select(g => new RiskInsightsReportDetail
|
||||
{
|
||||
UserGuid = g.Key.UserGuid,
|
||||
UserName = g.Key.UserName,
|
||||
Email = g.Key.Email,
|
||||
UsesKeyConnector = g.Key.UsesKeyConnector,
|
||||
CipherIds = g
|
||||
.Select(x => x.CipherId.ToString())
|
||||
.Distinct()
|
||||
});
|
||||
|
||||
return insightsDetails;
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Dirt.Repositories;
|
||||
|
||||
public interface IOrganizationApplicationRepository : IRepository<OrganizationApplication, Guid>
|
||||
{
|
||||
Task<ICollection<OrganizationApplication>> GetByOrganizationIdAsync(Guid organizationId);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.Repositories;
|
||||
|
||||
public interface IOrganizationMemberBaseDetailRepository
|
||||
{
|
||||
Task<IEnumerable<OrganizationMemberBaseDetail>> GetOrganizationMemberBaseDetailsByOrganizationId(Guid organizationId);
|
||||
}
|
12
src/Core/Dirt/Repositories/IOrganizationReportRepository.cs
Normal file
12
src/Core/Dirt/Repositories/IOrganizationReportRepository.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Dirt.Repositories;
|
||||
|
||||
public interface IOrganizationReportRepository : IRepository<OrganizationReport, Guid>
|
||||
{
|
||||
Task<ICollection<OrganizationReport>> GetByOrganizationIdAsync(Guid organizationId);
|
||||
|
||||
Task<OrganizationReport> GetLatestByOrganizationIdAsync(Guid organizationId);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.Repositories;
|
||||
namespace Bit.Core.Dirt.Repositories;
|
||||
|
||||
public interface IPasswordHealthReportApplicationRepository : IRepository<PasswordHealthReportApplication, Guid>
|
||||
{
|
@ -1,20 +0,0 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
public class RotateUserKeyData
|
||||
{
|
||||
public string MasterPasswordHash { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string PrivateKey { get; set; }
|
||||
public IEnumerable<Cipher> Ciphers { get; set; }
|
||||
public IEnumerable<Folder> Folders { get; set; }
|
||||
public IReadOnlyList<Send> Sends { get; set; }
|
||||
public IEnumerable<EmergencyAccess> EmergencyAccesses { get; set; }
|
||||
public IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
||||
public IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user