1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

Merging in main

This commit is contained in:
Andy Pixley
2025-06-30 15:48:47 -04:00
308 changed files with 38895 additions and 4236 deletions

View File

@ -3,6 +3,9 @@ services:
image: mcr.microsoft.com/devcontainers/dotnet:8.0 image: mcr.microsoft.com/devcontainers/dotnet:8.0
volumes: volumes:
- ../../:/workspace:cached - ../../:/workspace:cached
env_file:
- path: ../../dev/.env
required: false
# Overrides default command so things don't shut down after the process ends. # Overrides default command so things don't shut down after the process ends.
command: sleep infinity command: sleep infinity

View File

@ -1,17 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export DEV_DIR=/workspace/dev export REPO_ROOT="$(git rev-parse --show-toplevel)"
export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev
git config --global --add safe.directory /workspace git config --global --add safe.directory /workspace
get_installation_id_and_key() { if [[ -z "${CODESPACES}" ]]; then
pushd ./dev >/dev/null || exit allow_interactive=1
echo "Please enter your installation id and key from https://bitwarden.com/host:" else
read -r -p "Installation id: " INSTALLATION_ID echo "Doing non-interactive setup"
read -r -p "Installation key: " INSTALLATION_KEY allow_interactive=0
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" | fi
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
secrets.json.example >secrets.json # create/overwrite secrets.json get_option() {
popd >/dev/null || exit # 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() { remove_comments() {
@ -26,51 +51,70 @@ remove_comments() {
configure_other_vars() { configure_other_vars() {
pushd ./dev >/dev/null || exit 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 # 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" SQL_CONNECTION_STRING="Server=localhost;Database=vault_dev;User Id=SA;Password=$DB_PASSWORD;Encrypt=True;TrustServerCertificate=True"
jq \ jq \
".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" | ".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" |
.globalSettings.postgreSql.connectionString = \"Host=localhost;Username=postgres;Password=$DB_PASSWORD;Database=vault_dev;Include Error Detail=true\" | .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\"" \ .globalSettings.mySql.connectionString = \"server=localhost;uid=root;pwd=$DB_PASSWORD;database=vault_dev\"" \
.secrets.json.tmp >secrets.json .secrets.json.tmp >secrets.json
rm .secrets.json.tmp rm "$REPO_ROOT/dev/.secrets.json.tmp"
popd >/dev/null || exit popd >/dev/null || exit
} }
one_time_setup() { one_time_setup() {
read -r -p \ if [[ ! -f "$REPO_ROOT/dev/dev.pfx" ]]; then
"Would you like to configure your secrets and certificates for the first time? # We do not have the cert file
WARNING: This will overwrite any existing secrets.json and certificate files. if [[ ! -z "${DEV_CERT_CONTENTS}" ]]; then
Proceed? [y/N] " response # Make file for them
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Making $REPO_ROOT/dev/dev.pfx file for you based on DEV_CERT_CONTENTS environment variable."
echo "Running one-time setup script..." # Assume content is base64 encoded
sleep 1 echo "$DEV_CERT_CONTENTS" | base64 -d > "$REPO_ROOT/dev/dev.pfx"
read -r -p \ else
"Place the secrets.json and dev.pfx files from our shared Collection in the ./dev directory. 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." 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 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..." echo "Installing Az module. This will take ~a minute..."
pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force" pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force"
pwsh ./dev/setup_azurite.ps1 pwsh "$REPO_ROOT/dev/setup_azurite.ps1"
fi
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
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..." echo "Running migrations..."
sleep 5 # wait for DB container to start 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 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 if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
install_stripe_cli install_stripe_cli
fi fi
@ -88,11 +132,4 @@ install_stripe_cli() {
sudo apt install -y stripe sudo apt install -y stripe
} }
# main one_time_setup
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

View File

@ -4,6 +4,9 @@ on:
workflow_call: workflow_call:
pull_request: pull_request:
types: [labeled, unlabeled, opened, reopened, synchronize] types: [labeled, unlabeled, opened, reopened, synchronize]
permissions: {}
jobs: jobs:
enforce-label: 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') }} 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') }}

View File

@ -16,6 +16,9 @@ jobs:
changed-files: changed-files:
name: Check for file changes name: Check for file changes
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions:
contents: read
pull-requests: write
outputs: outputs:
changes: ${{steps.check-changes.outputs.changes_detected}} changes: ${{steps.check-changes.outputs.changes_detected}}

View File

@ -59,6 +59,8 @@ jobs:
name: Create GitHub release name: Create GitHub release
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: setup needs: setup
permissions:
contents: write
steps: steps:
- name: Download latest release Docker stubs - name: Download latest release Docker stubs
if: ${{ inputs.release_type != 'Dry Run' }} if: ${{ inputs.release_type != 'Dry Run' }}

View File

@ -22,11 +22,12 @@ on:
required: false required: false
type: string type: string
permissions: {}
jobs: jobs:
setup: setup:
name: Setup name: Setup
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: {}
outputs: outputs:
branch: ${{ steps.set-branch.outputs.branch }} branch: ${{ steps.set-branch.outputs.branch }}
steps: steps:
@ -45,74 +46,12 @@ jobs:
echo "branch=$BRANCH" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT
cut_branch:
name: Cut branch
if: ${{ needs.setup.outputs.branch != 'none' }}
needs: setup
permissions:
contents: write
id-token: write
runs-on: ubuntu-24.04
steps:
- name: Log in to Azure
id: azure-login
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.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: bump_version:
name: Bump Version name: Bump Version
if: ${{ always() }} if: ${{ always() }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- cut_branch
- setup - setup
permissions:
contents: write
id-token: write
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
steps: steps:
@ -122,30 +61,12 @@ jobs:
with: with:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Log in to Azure
id: azure-login
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token - name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token id: app-token
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -230,120 +151,45 @@ jobs:
- name: Push changes - name: Push changes
run: git push run: git push
cut_branch:
cherry_pick: name: Cut branch
name: Cherry-Pick Commit(s)
if: ${{ needs.setup.outputs.branch != 'none' }} if: ${{ needs.setup.outputs.branch != 'none' }}
runs-on: ubuntu-24.04
needs: needs:
- bump_version
- setup - setup
permissions: - bump_version
contents: write runs-on: ubuntu-24.04
id-token: write
steps: steps:
- name: Log in to Azure
id: azure-login
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token - name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token id: app-token
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 ref: ${{ inputs.target_ref }}
ref: main
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Configure Git - name: Check if ${{ needs.setup.outputs.branch }} branch exists
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)
env: env:
CUT_BRANCH: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
# Function for cherry-picking if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
cherry_pick () { echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
local source_branch=$1 exit 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
fi 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: move_future_db_scripts:
name: Move finalization database scripts name: Move finalization database scripts
needs: cherry_pick needs: cut_branch
uses: ./.github/workflows/_move_finalization_db_scripts.yml uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit secrets: inherit
permissions:
contents: write
pull-requests: write
id-token: write
actions: read

View File

@ -8,6 +8,11 @@ jobs:
stale: stale:
name: Check for stale issues and PRs name: Check for stale issues and PRs
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions:
actions: write
contents: read
issues: write
pull-requests: write
steps: steps:
- name: Check - name: Check
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0

View File

@ -31,10 +31,17 @@ on:
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions - "src/**/Entities/**/*.cs" # Database entity definitions
permissions:
contents: read
jobs: jobs:
test: test:
name: Run tests name: Run tests
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions:
contents: read
actions: read
checks: write
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

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

View File

@ -26,3 +26,12 @@ IDENTITY_PROXY_PORT=33756
# Optional RabbitMQ configuration # Optional RabbitMQ configuration
RABBITMQ_DEFAULT_USER=bitwarden RABBITMQ_DEFAULT_USER=bitwarden
RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123 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

View File

@ -6,6 +6,7 @@ using Bit.Admin.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
@ -34,14 +35,13 @@ namespace Bit.Admin.AdminConsole.Controllers;
public class ProvidersController : Controller public class ProvidersController : Controller
{ {
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService; private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly IUserService _userService;
private readonly ICreateProviderCommand _createProviderCommand; private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
@ -54,14 +54,13 @@ public class ProvidersController : Controller
public ProvidersController( public ProvidersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationService organizationService, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderService providerService, IProviderService providerService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IUserService userService,
ICreateProviderCommand createProviderCommand, ICreateProviderCommand createProviderCommand,
IFeatureService featureService, IFeatureService featureService,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
@ -71,14 +70,13 @@ public class ProvidersController : Controller
IStripeAdapter stripeAdapter) IStripeAdapter stripeAdapter)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_providerService = providerService; _providerService = providerService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_userService = userService;
_createProviderCommand = createProviderCommand; _createProviderCommand = createProviderCommand;
_featureService = featureService; _featureService = featureService;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
@ -459,7 +457,7 @@ public class ProvidersController : Controller
} }
var organization = model.CreateOrganization(provider); 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); await _providerService.AddOrganization(providerId, organization.Id, null);
return RedirectToAction("Edit", "Providers", new { id = providerId }); return RedirectToAction("Edit", "Providers", new { id = providerId });

View File

@ -59,6 +59,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
gosu \ gosu \
curl \ curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy app from the build stage # Copy app from the build stage

View File

@ -403,16 +403,15 @@ public class OrganizationUsersController : Controller
} }
[HttpPost("{id}/confirm")] [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(orgId))
if (!await _currentContext.ManageUsers(orgGuidId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var userId = _userService.GetProperUserId(User); 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")] [HttpPost("confirm")]
@ -521,7 +520,9 @@ public class OrganizationUsersController : Controller
.Concat(readonlyCollectionAccess) .Concat(readonlyCollectionAccess)
.ToList(); .ToList();
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId, var existingUserType = organizationUser.Type;
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
collectionsToSave, groupsToSave); collectionsToSave, groupsToSave);
} }

View File

@ -2,7 +2,7 @@
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; 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.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
#nullable enable #nullable enable
@ -15,6 +15,8 @@ public class OrganizationIntegrationConfigurationRequestModel
[Required] [Required]
public EventType EventType { get; set; } public EventType EventType { get; set; }
public string? Filters { get; set; }
public string? Template { get; set; } public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType) public bool IsValidForType(IntegrationType integrationType)
@ -24,9 +26,13 @@ public class OrganizationIntegrationConfigurationRequestModel
case IntegrationType.CloudBillingSync or IntegrationType.Scim: case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false; return false;
case IntegrationType.Slack: case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<SlackIntegrationConfiguration>(); return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<SlackIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Webhook: case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<WebhookIntegrationConfiguration>(); return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<WebhookIntegrationConfiguration>() &&
IsFiltersValid();
default: default:
return false; return false;
@ -39,6 +45,7 @@ public class OrganizationIntegrationConfigurationRequestModel
{ {
OrganizationIntegrationId = organizationIntegrationId, OrganizationIntegrationId = organizationIntegrationId,
Configuration = Configuration, Configuration = Configuration,
Filters = Filters,
EventType = EventType, EventType = EventType,
Template = Template Template = Template
}; };
@ -48,6 +55,7 @@ public class OrganizationIntegrationConfigurationRequestModel
{ {
currentConfiguration.Configuration = Configuration; currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType; currentConfiguration.EventType = EventType;
currentConfiguration.Filters = Filters;
currentConfiguration.Template = Template; currentConfiguration.Template = Template;
return currentConfiguration; return currentConfiguration;
@ -70,4 +78,22 @@ public class OrganizationIntegrationConfigurationRequestModel
return false; return false;
} }
} }
private bool IsFiltersValid()
{
if (Filters is null)
{
return true;
}
try
{
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(Filters);
return filters is not null;
}
catch
{
return false;
}
}
} }

View File

@ -60,6 +60,10 @@ public class OrganizationUserConfirmRequestModel
{ {
[Required] [Required]
public string Key { get; set; } public string Key { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string DefaultUserCollectionName { get; set; }
} }
public class OrganizationUserBulkConfirmRequestModelEntry public class OrganizationUserBulkConfirmRequestModelEntry

View File

@ -17,11 +17,13 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
Configuration = organizationIntegrationConfiguration.Configuration; Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate; CreationDate = organizationIntegrationConfiguration.CreationDate;
EventType = organizationIntegrationConfiguration.EventType; EventType = organizationIntegrationConfiguration.EventType;
Filters = organizationIntegrationConfiguration.Filters;
Template = organizationIntegrationConfiguration.Template; Template = organizationIntegrationConfiguration.Template;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
public string? Configuration { get; set; } public string? Configuration { get; set; }
public string? Filters { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public EventType EventType { get; set; } public EventType EventType { get; set; }
public string? Template { get; set; } public string? Template { get; set; }

View File

@ -177,9 +177,10 @@ public class MembersController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var existingUserType = existingUser.Type;
var updatedUser = model.ToOrganizationUser(existingUser); var updatedUser = model.ToOrganizationUser(existingUser);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups); await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
MemberResponseModel response = null; MemberResponseModel response = null;
if (existingUser.UserId.HasValue) if (existingUser.UserId.HasValue)
{ {

View File

@ -1,34 +1,21 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.Accounts; 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.Request.Accounts;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request.Accounts; 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.TdeOffboardingPassword.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Api.Response; using Bit.Core.Models.Api.Response;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -45,22 +32,9 @@ public class AccountsController : Controller
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService; 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( public AccountsController(
IOrganizationService organizationService, IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -69,17 +43,8 @@ public class AccountsController : Controller
IPolicyService policyService, IPolicyService policyService,
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
IRotateUserKeyCommand rotateUserKeyCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService, 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
) )
{ {
_organizationService = organizationService; _organizationService = organizationService;
@ -89,15 +54,8 @@ public class AccountsController : Controller
_policyService = policyService; _policyService = policyService;
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
_rotateUserKeyCommand = rotateUserKeyCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService; _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); 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")] [HttpPost("security-stamp")]
public async Task PostSecurityStamp([FromBody] SecretVerificationRequestModel model) public async Task PostSecurityStamp([FromBody] SecretVerificationRequestModel model)
{ {

View File

@ -1,5 +1,6 @@
using Bit.Api.Auth.Models.Response; using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
@ -7,6 +8,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -14,31 +16,23 @@ namespace Bit.Api.Auth.Controllers;
[Route("auth-requests")] [Route("auth-requests")]
[Authorize("Application")] [Authorize("Application")]
public class AuthRequestsController : Controller public class AuthRequestsController(
IUserService userService,
IAuthRequestRepository authRequestRepository,
IGlobalSettings globalSettings,
IAuthRequestService authRequestService) : Controller
{ {
private readonly IUserService _userService; private readonly IUserService _userService = userService;
private readonly IAuthRequestRepository _authRequestRepository; private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings = globalSettings;
private readonly IAuthRequestService _authRequestService; private readonly IAuthRequestService _authRequestService = authRequestService;
public AuthRequestsController(
IUserService userService,
IAuthRequestRepository authRequestRepository,
IGlobalSettings globalSettings,
IAuthRequestService authRequestService)
{
_userService = userService;
_authRequestRepository = authRequestRepository;
_globalSettings = globalSettings;
_authRequestService = authRequestService;
}
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<AuthRequestResponseModel>> Get() public async Task<ListResponseModel<AuthRequestResponseModel>> Get()
{ {
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId);
var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList(); var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
return new ListResponseModel<AuthRequestResponseModel>(responses); return new ListResponseModel<AuthRequestResponseModel>(responses);
} }
@ -56,6 +50,16 @@ public class AuthRequestsController : Controller
return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault);
} }
[HttpGet("pending")]
[RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)]
public async Task<ListResponseModel<PendingAuthRequestResponseModel>> GetPendingAuthRequestsAsync()
{
var userId = _userService.GetProperUserId(User).Value;
var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId);
var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault));
return new ListResponseModel<PendingAuthRequestResponseModel>(responses);
}
[HttpGet("{id}/response")] [HttpGet("{id}/response")]
[AllowAnonymous] [AllowAnonymous]
public async Task<AuthRequestResponseModel> GetResponse(Guid id, [FromQuery] string code) public async Task<AuthRequestResponseModel> GetResponse(Guid id, [FromQuery] string code)

View File

@ -4,7 +4,6 @@ using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response; using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -72,7 +71,7 @@ public class EmergencyAccessController : Controller
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);
var policies = await _emergencyAccessService.GetPoliciesAsync(id, user); var policies = await _emergencyAccessService.GetPoliciesAsync(id, user);
var responses = policies.Select<Policy, PolicyResponseModel>(policy => new PolicyResponseModel(policy)); var responses = policies?.Select(policy => new PolicyResponseModel(policy));
return new ListResponseModel<PolicyResponseModel>(responses); return new ListResponseModel<PolicyResponseModel>(responses);
} }

View File

@ -0,0 +1,15 @@
using Bit.Core.Auth.Models.Data;
namespace Bit.Api.Auth.Models.Response;
public class PendingAuthRequestResponseModel : AuthRequestResponseModel
{
public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request")
: base(authRequest, vaultUri, obj)
{
ArgumentNullException.ThrowIfNull(authRequest);
RequestDeviceId = authRequest.RequestDeviceId;
}
public Guid? RequestDeviceId { get; set; }
}

View File

@ -20,6 +20,8 @@ public class CollectionsController : Controller
{ {
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly ICollectionService _collectionService; private readonly ICollectionService _collectionService;
private readonly ICreateCollectionCommand _createCollectionCommand;
private readonly IUpdateCollectionCommand _updateCollectionCommand;
private readonly IDeleteCollectionCommand _deleteCollectionCommand; private readonly IDeleteCollectionCommand _deleteCollectionCommand;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
@ -29,6 +31,8 @@ public class CollectionsController : Controller
public CollectionsController( public CollectionsController(
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
ICollectionService collectionService, ICollectionService collectionService,
ICreateCollectionCommand createCollectionCommand,
IUpdateCollectionCommand updateCollectionCommand,
IDeleteCollectionCommand deleteCollectionCommand, IDeleteCollectionCommand deleteCollectionCommand,
IUserService userService, IUserService userService,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
@ -37,6 +41,8 @@ public class CollectionsController : Controller
{ {
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_collectionService = collectionService; _collectionService = collectionService;
_createCollectionCommand = createCollectionCommand;
_updateCollectionCommand = updateCollectionCommand;
_deleteCollectionCommand = deleteCollectionCommand; _deleteCollectionCommand = deleteCollectionCommand;
_userService = userService; _userService = userService;
_authorizationService = authorizationService; _authorizationService = authorizationService;
@ -153,7 +159,7 @@ public class CollectionsController : Controller
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly()).ToList() ?? new List<CollectionAccessSelection>(); var users = model.Users?.Select(g => g.ToSelectionReadOnly()).ToList() ?? new List<CollectionAccessSelection>();
await _collectionService.SaveAsync(collection, groups, users); await _createCollectionCommand.CreateAsync(collection, groups, users);
if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(orgId) == null && await _currentContext.ProviderUserForOrgAsync(orgId))) if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(orgId) == null && await _currentContext.ProviderUserForOrgAsync(orgId)))
{ {
@ -179,7 +185,7 @@ public class CollectionsController : Controller
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly());
await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); await _updateCollectionCommand.UpdateAsync(model.ToCollection(collection), groups, users);
if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(collection.OrganizationId) == null && await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId))) if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(collection.OrganizationId) == null && await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId)))
{ {
@ -192,19 +198,6 @@ public class CollectionsController : Controller
return new CollectionAccessDetailsResponseModel(collectionWithPermissions); 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();
}
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
}
[HttpPost("bulk-access")] [HttpPost("bulk-access")]
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
{ {
@ -255,18 +248,4 @@ public class CollectionsController : Controller
await _deleteCollectionCommand.DeleteManyAsync(collections); 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);
}
} }

View File

@ -206,7 +206,11 @@ public class DevicesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _deviceService.SaveAsync(model.ToData(), device); await _deviceService.SaveAsync(
model.ToData(),
device,
_currentContext.Organizations.Select(org => org.Id.ToString())
);
} }
[AllowAnonymous] [AllowAnonymous]

View File

@ -1,7 +1,8 @@
using Bit.Api.Dirt.Models; using Bit.Api.Dirt.Models;
using Bit.Api.Dirt.Models.Response; using Bit.Api.Dirt.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Context; 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.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
@ -17,24 +18,36 @@ namespace Bit.Api.Dirt.Controllers;
public class ReportsController : Controller public class ReportsController : Controller
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery; private readonly IMemberAccessReportQuery _memberAccessReportQuery;
private readonly IRiskInsightsReportQuery _riskInsightsReportQuery;
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand;
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
public ReportsController( public ReportsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery, IMemberAccessReportQuery memberAccessReportQuery,
IRiskInsightsReportQuery riskInsightsReportQuery,
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery, IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
IGetOrganizationReportQuery getOrganizationReportQuery,
IAddOrganizationReportCommand addOrganizationReportCommand,
IDropOrganizationReportCommand dropOrganizationReportCommand
) )
{ {
_currentContext = currentContext; _currentContext = currentContext;
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery; _memberAccessReportQuery = memberAccessReportQuery;
_riskInsightsReportQuery = riskInsightsReportQuery;
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
_getOrganizationReportQuery = getOrganizationReportQuery;
_addOrganizationReportCommand = addOrganizationReportCommand;
_dropOrganizationReportCommand = dropOrganizationReportCommand;
} }
/// <summary> /// <summary>
@ -54,9 +67,9 @@ public class ReportsController : Controller
throw new NotFoundException(); 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; return responses;
} }
@ -69,16 +82,16 @@ public class ReportsController : Controller
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns> /// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception> /// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-access/{orgId}")] [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)) if (!await _currentContext.AccessReports(orgId))
{ {
throw new NotFoundException(); 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; return responses;
} }
@ -87,13 +100,28 @@ public class ReportsController : Controller
/// Contains the organization member info, the cipher ids associated with the member, /// Contains the organization member info, the cipher ids associated with the member,
/// and details on their collections, groups, and permissions /// and details on their collections, groups, and permissions
/// </summary> /// </summary>
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param> /// <param name="request">Request parameters</param>
/// <returns>IEnumerable of MemberAccessCipherDetails</returns> /// <returns>
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request) /// 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 = var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request); return accessDetails;
return memberCipherDetails; }
/// <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> /// <summary>
@ -185,4 +213,72 @@ public class ReportsController : Controller
await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); 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);
}
} }

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Models.Data;
namespace Bit.Api.Dirt.Models.Response; namespace Bit.Api.Dirt.Models.Response;

View File

@ -1,5 +1,4 @@
using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.Models.Data;
namespace Bit.Api.Dirt.Models.Response; namespace Bit.Api.Dirt.Models.Response;
public class MemberCipherDetailsResponseModel public class MemberCipherDetailsResponseModel
@ -15,12 +14,12 @@ public class MemberCipherDetailsResponseModel
/// </summary> /// </summary>
public IEnumerable<string> CipherIds { get; set; } public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail)
{ {
this.UserGuid = memberAccessCipherDetails.UserGuid; this.UserGuid = reportDetail.UserGuid;
this.UserName = memberAccessCipherDetails.UserName; this.UserName = reportDetail.UserName;
this.Email = memberAccessCipherDetails.Email; this.Email = reportDetail.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; this.UsesKeyConnector = reportDetail.UsesKeyConnector;
this.CipherIds = memberAccessCipherDetails.CipherIds; this.CipherIds = reportDetail.CipherIds;
} }
} }

View File

@ -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.Exceptions;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -20,14 +23,14 @@ namespace Bit.Api.Platform.Push;
public class PushController : Controller public class PushController : Controller
{ {
private readonly IPushRegistrationService _pushRegistrationService; private readonly IPushRegistrationService _pushRegistrationService;
private readonly IPushNotificationService _pushNotificationService; private readonly IPushRelayer _pushRelayer;
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
public PushController( public PushController(
IPushRegistrationService pushRegistrationService, IPushRegistrationService pushRegistrationService,
IPushNotificationService pushNotificationService, IPushRelayer pushRelayer,
IWebHostEnvironment environment, IWebHostEnvironment environment,
ICurrentContext currentContext, ICurrentContext currentContext,
IGlobalSettings globalSettings) IGlobalSettings globalSettings)
@ -35,7 +38,7 @@ public class PushController : Controller
_currentContext = currentContext; _currentContext = currentContext;
_environment = environment; _environment = environment;
_pushRegistrationService = pushRegistrationService; _pushRegistrationService = pushRegistrationService;
_pushNotificationService = pushNotificationService; _pushRelayer = pushRelayer;
_globalSettings = globalSettings; _globalSettings = globalSettings;
} }
@ -74,31 +77,50 @@ public class PushController : Controller
} }
[HttpPost("send")] [HttpPost("send")]
public async Task SendAsync([FromBody] PushSendRequestModel model) public async Task SendAsync([FromBody] PushSendRequestModel<JsonElement> model)
{ {
CheckUsage(); 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."); throw new BadRequestException("InstallationId does not match current context.");
} }
await _pushNotificationService.SendPayloadToInstallationAsync( target = NotificationTarget.Installation;
_currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier), targetId = _currentContext.InstallationId.Value;
Prefix(model.DeviceId), model.ClientType);
} }
else if (!string.IsNullOrWhiteSpace(model.UserId)) else if (model.UserId.HasValue)
{ {
await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId), target = NotificationTarget.User;
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); targetId = model.UserId.Value;
} }
else if (!string.IsNullOrWhiteSpace(model.OrganizationId)) else if (model.OrganizationId.HasValue)
{ {
await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId), target = NotificationTarget.Organization;
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); 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) private string Prefix(string value)

View File

@ -2,6 +2,8 @@
using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -14,18 +16,18 @@ namespace Bit.Api.Public.Controllers;
public class CollectionsController : Controller public class CollectionsController : Controller
{ {
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly ICollectionService _collectionService; private readonly IUpdateCollectionCommand _updateCollectionCommand;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
public CollectionsController( public CollectionsController(
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
ICollectionService collectionService, IUpdateCollectionCommand updateCollectionCommand,
ICurrentContext currentContext, ICurrentContext currentContext,
IApplicationCacheService applicationCacheService) IApplicationCacheService applicationCacheService)
{ {
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_collectionService = collectionService; _updateCollectionCommand = updateCollectionCommand;
_currentContext = currentContext; _currentContext = currentContext;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
} }
@ -93,7 +95,7 @@ public class CollectionsController : Controller
} }
var updatedCollection = model.ToCollection(existingCollection); var updatedCollection = model.ToCollection(existingCollection);
var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection()).ToList(); var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection()).ToList();
await _collectionService.SaveAsync(updatedCollection, associations); await _updateCollectionCommand.UpdateAsync(updatedCollection, associations, null);
var response = new CollectionResponseModel(updatedCollection, associations); var response = new CollectionResponseModel(updatedCollection, associations);
return new JsonResult(response); return new JsonResult(response);
} }
@ -115,6 +117,12 @@ public class CollectionsController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
if (collection.Type == CollectionType.DefaultUserCollection)
{
return new BadRequestObjectResult(new ErrorResponseModel("You cannot delete a collection with the type as DefaultUserCollection."));
}
await _collectionRepository.DeleteAsync(collection); await _collectionRepository.DeleteAsync(collection);
return new OkResult(); return new OkResult();
} }

View File

@ -15,5 +15,6 @@ public class OrganizationIntegrationConfiguration : ITableObject<Guid>
public string? Template { get; set; } public string? Template { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public string? Filters { get; set; }
public void SetNewId() => Id = CoreHelpers.GenerateComb(); public void SetNewId() => Id = CoreHelpers.GenerateComb();
} }

View File

@ -7,7 +7,7 @@ public enum PolicyType : byte
PasswordGenerator = 2, PasswordGenerator = 2,
SingleOrg = 3, SingleOrg = 3,
RequireSso = 4, RequireSso = 4,
PersonalOwnership = 5, OrganizationDataOwnership = 5,
DisableSend = 6, DisableSend = 6,
SendOptions = 7, SendOptions = 7,
ResetPassword = 8, ResetPassword = 8,
@ -35,7 +35,7 @@ public static class PolicyTypeExtensions
PolicyType.PasswordGenerator => "Password generator", PolicyType.PasswordGenerator => "Password generator",
PolicyType.SingleOrg => "Single organization", PolicyType.SingleOrg => "Single organization",
PolicyType.RequireSso => "Require single sign-on authentication", PolicyType.RequireSso => "Require single sign-on authentication",
PolicyType.PersonalOwnership => "Remove individual vault", PolicyType.OrganizationDataOwnership => "Enforce organization data ownership",
PolicyType.DisableSend => "Remove Send", PolicyType.DisableSend => "Remove Send",
PolicyType.SendOptions => "Send options", PolicyType.SendOptions => "Send options",
PolicyType.ResetPassword => "Account recovery administration", PolicyType.ResetPassword => "Account recovery administration",

View File

@ -2,7 +2,7 @@
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public interface IIntegrationMessage public interface IIntegrationMessage
{ {

View File

@ -0,0 +1,10 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationFilterGroup
{
public bool AndOperator { get; init; } = true;
public List<IntegrationFilterRule>? Rules { get; init; }
public List<IntegrationFilterGroup>? Groups { get; init; }
}

View File

@ -0,0 +1,10 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public enum IntegrationFilterOperation
{
Equals = 0,
NotEquals = 1,
In = 2,
NotIn = 3
}

View File

@ -0,0 +1,11 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationFilterRule
{
public required string Property { get; set; }
public required IntegrationFilterOperation Operation { get; set; }
public required object? Value { get; set; }
}

View File

@ -1,6 +1,6 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationHandlerResult public class IntegrationHandlerResult
{ {

View File

@ -3,7 +3,7 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationMessage : IIntegrationMessage public class IntegrationMessage : IIntegrationMessage
{ {

View File

@ -5,7 +5,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationTemplateContext(EventMessage eventMessage) public class IntegrationTemplateContext(EventMessage eventMessage)
{ {

View File

@ -0,0 +1,5 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegration(string Token);

View File

@ -0,0 +1,5 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfiguration(string ChannelId);

View File

@ -0,0 +1,5 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfigurationDetails(string ChannelId, string Token);

View File

@ -0,0 +1,5 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfiguration(string Url, string? Scheme = null, string? Token = null);

View File

@ -0,0 +1,5 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfigurationDetails(string Url, string? Scheme = null, string? Token = null);

View File

@ -1,5 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -1,5 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegrationConfiguration(string channelId);

View File

@ -1,5 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -1,5 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record WebhookIntegrationConfiguration(string url);

View File

@ -1,5 +0,0 @@
#nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record WebhookIntegrationConfigurationDetails(string url);

View File

@ -12,6 +12,7 @@ public class OrganizationIntegrationConfigurationDetails
public IntegrationType IntegrationType { get; set; } public IntegrationType IntegrationType { get; set; }
public EventType EventType { get; set; } public EventType EventType { get; set; }
public string? Configuration { get; set; } public string? Configuration { get; set; }
public string? Filters { get; set; }
public string? IntegrationConfiguration { get; set; } public string? IntegrationConfiguration { get; set; }
public string? Template { get; set; } public string? Template { get; set; }

View File

@ -163,6 +163,11 @@ public class UpdateGroupCommand : IUpdateGroupCommand
// Use generic error message to avoid enumeration // Use generic error message to avoid enumeration
throw new NotFoundException(); throw new NotFoundException();
} }
if (collections.Any(c => c.Type == CollectionType.DefaultUserCollection))
{
throw new BadRequestException("You cannot modify group access for collections with the type as DefaultUserCollection.");
}
} }
private async Task ValidateMemberAccessAsync(Group originalGroup, private async Task ValidateMemberAccessAsync(Group originalGroup,

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -28,6 +29,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ICollectionRepository _collectionRepository;
public ConfirmOrganizationUserCommand( public ConfirmOrganizationUserCommand(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -41,7 +43,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
IPolicyService policyService, IPolicyService policyService,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService) IFeatureService featureService,
ICollectionRepository collectionRepository)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -55,10 +58,11 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_featureService = featureService; _featureService = featureService;
_collectionRepository = collectionRepository;
} }
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId) Guid confirmingUserId, string defaultUserCollectionName = null)
{ {
var result = await ConfirmUsersAsync( var result = await ConfirmUsersAsync(
organizationId, organizationId,
@ -75,6 +79,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
{ {
throw new BadRequestException(error); throw new BadRequestException(error);
} }
await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
return orgUser; return orgUser;
} }
@ -213,4 +220,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) .Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString()); .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);
}
} }

View File

@ -15,9 +15,10 @@ public interface IConfirmOrganizationUserCommand
/// <param name="organizationUserId">The ID of the organization user to confirm.</param> /// <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="key">The encrypted organization key for the user.</param>
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</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> /// <returns>The confirmed organization user.</returns>
/// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception> /// <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> /// <summary>
/// Confirms multiple organization users who have accepted their invitations. /// Confirms multiple organization users who have accepted their invitations.

View File

@ -1,11 +1,12 @@
#nullable enable #nullable enable
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IUpdateOrganizationUserCommand public interface IUpdateOrganizationUserCommand
{ {
Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId, Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId,
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess); List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
} }

View File

@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
/// Update an organization user. /// Update an organization user.
/// </summary> /// </summary>
/// <param name="organizationUser">The modified organization user to save.</param> /// <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="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="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> /// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
/// <exception cref="BadRequestException"></exception> /// <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) List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
{ {
// Avoid multiple enumeration // Avoid multiple enumeration
@ -83,15 +85,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
throw new NotFoundException(); throw new NotFoundException();
} }
if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization);
{
// 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.");
}
}
if (collectionAccessList.Count != 0) if (collectionAccessList.Count != 0)
{ {
@ -151,6 +145,40 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated); 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, private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
ICollection<CollectionAccessSelection> collectionAccess) ICollection<CollectionAccessSelection> collectionAccess)
{ {
@ -171,6 +199,11 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
// Use generic error message to avoid enumeration // Use generic error message to avoid enumeration
throw new NotFoundException(); throw new NotFoundException();
} }
if (collections.Any(c => c.Type == CollectionType.DefaultUserCollection))
{
throw new BadRequestException("You cannot modify member access for collections with the type as DefaultUserCollection.");
}
} }
private async Task ValidateGroupAccessAsync(OrganizationUser originalUser, private async Task ValidateGroupAccessAsync(OrganizationUser originalUser,

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -34,7 +34,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, OrganizationDataOwnershipPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>();
} }

View File

@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -0,0 +1,11 @@
#nullable enable
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;
public interface IIntegrationFilterService
{
bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message);
}

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -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.AdminConsole.Models.Business;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -42,7 +41,6 @@ public interface IOrganizationService
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId, Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId); 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 ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using RabbitMQ.Client; using RabbitMQ.Client;
using RabbitMQ.Client.Events; using RabbitMQ.Client.Events;

View File

@ -1,85 +0,0 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
namespace Bit.Core.Services;
public class EventIntegrationHandler<T>(
IntegrationType integrationType,
IEventIntegrationPublisher eventIntegrationPublisher,
IOrganizationIntegrationConfigurationRepository configurationRepository,
IUserRepository userRepository,
IOrganizationRepository organizationRepository)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
if (eventMessage.OrganizationId is not Guid organizationId)
{
return;
}
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
integrationType,
eventMessage.Type);
foreach (var configuration in configurations)
{
var template = configuration.Template ?? string.Empty;
var context = await BuildContextAsync(eventMessage, template);
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
var config = configuration.MergedConfiguration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}");
var message = new IntegrationMessage<T>
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
DelayUntilDate = null
};
await eventIntegrationPublisher.PublishAsync(message);
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
{
var context = new IntegrationTemplateContext(eventMessage);
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
{
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
{
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue)
{
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value);
}
return context;
}
}

View File

@ -33,6 +33,13 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await _processor.StartProcessingAsync(cancellationToken); 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) internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
{ {
_logger.LogError( _logger.LogError(
@ -49,16 +56,4 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId); await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
await args.CompleteMessageAsync(args.Message); 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();
}
} }

View File

@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus; 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.Enums;
using Bit.Core.Settings; using Bit.Core.Settings;

View File

@ -0,0 +1,110 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class EventIntegrationHandler<T>(
IntegrationType integrationType,
IEventIntegrationPublisher eventIntegrationPublisher,
IIntegrationFilterService integrationFilterService,
IOrganizationIntegrationConfigurationRepository configurationRepository,
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
ILogger<EventIntegrationHandler<T>> logger)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
if (eventMessage.OrganizationId is not Guid organizationId)
{
return;
}
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
integrationType,
eventMessage.Type);
foreach (var configuration in configurations)
{
try
{
if (configuration.Filters is string filterJson)
{
// Evaluate filters - if false, then discard and do not process
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(filterJson)
?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup");
if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage))
{
continue;
}
}
// Valid filter - assemble message and publish to Integration topic/exchange
var template = configuration.Template ?? string.Empty;
var context = await BuildContextAsync(eventMessage, template);
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
var config = configuration.MergedConfiguration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration");
var message = new IntegrationMessage<T>
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
DelayUntilDate = null
};
await eventIntegrationPublisher.PublishAsync(message);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters",
typeof(T).Name,
configuration.Id);
}
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
{
var context = new IntegrationTemplateContext(eventMessage);
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
{
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
{
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue)
{
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value);
}
return context;
}
}

View File

@ -0,0 +1,51 @@
#nullable enable
using System.Linq.Expressions;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;
public delegate bool IntegrationFilter(EventMessage message, object? value);
public static class IntegrationFilterFactory
{
public static IntegrationFilter BuildEqualityFilter<T>(string propertyName)
{
var param = Expression.Parameter(typeof(EventMessage), "m");
var valueParam = Expression.Parameter(typeof(object), "val");
var property = Expression.PropertyOrField(param, propertyName);
var typedVal = Expression.Convert(valueParam, typeof(T));
var body = Expression.Equal(property, typedVal);
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(body, param, valueParam);
return new IntegrationFilter(lambda.Compile());
}
public static IntegrationFilter BuildInFilter<T>(string propertyName)
{
var param = Expression.Parameter(typeof(EventMessage), "m");
var valueParam = Expression.Parameter(typeof(object), "val");
var property = Expression.PropertyOrField(param, propertyName);
var method = typeof(Enumerable)
.GetMethods()
.FirstOrDefault(m =>
m.Name == "Contains"
&& m.GetParameters().Length == 2)
?.MakeGenericMethod(typeof(T));
if (method is null)
{
throw new InvalidOperationException("Could not find Contains method.");
}
var listType = typeof(IEnumerable<T>);
var castedList = Expression.Convert(valueParam, listType);
var containsCall = Expression.Call(method, castedList, property);
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(containsCall, param, valueParam);
return new IntegrationFilter(lambda.Compile());
}
}

View File

@ -0,0 +1,110 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;
public class IntegrationFilterService : IIntegrationFilterService
{
private readonly Dictionary<string, IntegrationFilter> _equalsFilters = new();
private readonly Dictionary<string, IntegrationFilter> _inFilters = new();
private static readonly string[] _filterableProperties = new[]
{
"UserId",
"InstallationId",
"ProviderId",
"CipherId",
"CollectionId",
"GroupId",
"PolicyId",
"OrganizationUserId",
"ProviderUserId",
"ProviderOrganizationId",
"ActingUserId",
"SecretId",
"ServiceAccountId"
};
public IntegrationFilterService()
{
BuildFilters();
}
public bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message)
{
var ruleResults = group.Rules?.Select(
rule => EvaluateRule(rule, message)
) ?? Enumerable.Empty<bool>();
var groupResults = group.Groups?.Select(
innerGroup => EvaluateFilterGroup(innerGroup, message)
) ?? Enumerable.Empty<bool>();
var results = ruleResults.Concat(groupResults);
return group.AndOperator ? results.All(r => r) : results.Any(r => r);
}
private bool EvaluateRule(IntegrationFilterRule rule, EventMessage message)
{
var key = rule.Property;
return rule.Operation switch
{
IntegrationFilterOperation.Equals => _equalsFilters.TryGetValue(key, out var equals) &&
equals(message, ToGuid(rule.Value)),
IntegrationFilterOperation.NotEquals => !(_equalsFilters.TryGetValue(key, out var equals) &&
equals(message, ToGuid(rule.Value))),
IntegrationFilterOperation.In => _inFilters.TryGetValue(key, out var inList) &&
inList(message, ToGuidList(rule.Value)),
IntegrationFilterOperation.NotIn => !(_inFilters.TryGetValue(key, out var inList) &&
inList(message, ToGuidList(rule.Value))),
_ => false
};
}
private void BuildFilters()
{
foreach (var property in _filterableProperties)
{
_equalsFilters[property] = IntegrationFilterFactory.BuildEqualityFilter<Guid?>(property);
_inFilters[property] = IntegrationFilterFactory.BuildInFilter<Guid?>(property);
}
}
private static Guid? ToGuid(object? value)
{
if (value is Guid guid)
{
return guid;
}
if (value is string stringValue)
{
return Guid.Parse(stringValue);
}
if (value is JsonElement jsonElement)
{
return jsonElement.GetGuid();
}
throw new InvalidCastException("Could not convert value to Guid");
}
private static IEnumerable<Guid?> ToGuidList(object? value)
{
if (value is IEnumerable<Guid?> guidList)
{
return guidList;
}
if (value is JsonElement { ValueKind: JsonValueKind.Array } jsonElement)
{
var list = new List<Guid?>();
foreach (var item in jsonElement.EnumerateArray())
{
list.Add(ToGuid(item));
}
return list;
}
throw new InvalidCastException("Could not convert value to Guid[]");
}
}

View File

@ -0,0 +1,421 @@
# 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.
## Filtering
In addition to the ability to configure integrations mentioned above, organization admins can
also add `Filters` stored in the `OrganizationIntegrationConfiguration`. Filters are completely
optional and as simple or complex as organization admins want to make them. These are stored in
the database as JSON and serialized into an `IntegrationFilterGroup`. This is then passed to
the `IntegrationFilterService`, which evaluates it to a `bool`. If it's `true`, the integration
proceeds as above. If it's `false`, we ignore this event and do not route it to the integration
level.
### `IntegrationFilterGroup`
Logical AND / OR grouping of a number of rules and other subgroups.
| Property | Description |
|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `AndOperator` | Indicates whether **all** (`true`) or **any** (`false`) of the `Rules` and `Groups` must be true. This applies to _both_ the inner group and the list of rules; for instance, if this group contained Rule1 and Rule2 as well as Group1 and Group2:<br/><br/>`true`: `Rule1 && Rule2 && Group1 && Group2`<br>`false`: `Rule1 \|\| Rule2 \|\| Group1 \|\| Group2` |
| `Rules` | A list of `IntegrationFilterRule`. Can be null or empty, in which case it will return `true`. |
| `Groups` | A list of nested `IntegrationFilterGroup`. Can be null or empty, in which case it will return `true`. |
### `IntegrationFilterRule`
The core of the filtering framework to determine if the data in this specific EventMessage
matches the data for which the filter is searching.
| Property | Description |
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Property` | The property on `EventMessage` to evaluate (e.g., `CollectionId`). |
| `Operation` | The comparison to perform between the property and `Value`. <br><br>**Supported operations:**<br>• `Equals`: `Guid` equals `Value`<br>• `NotEquals`: logical inverse of `Equals`<br>• `In`: `Guid` is in `Value` list<br>• `NotIn`: logical inverse of `In` |
| `Value` | The comparison value. Type depends on `Operation`: <br>• `Equals`, `NotEquals`: `Guid`<br>• `In`, `NotIn`: list of `Guid` |
```mermaid
graph TD
A[IntegrationFilterGroup]
A -->|Has 0..many| B1[IntegrationFilterRule]
A --> D1[And Operator]
A -->|Has 0..many| C1[Nested IntegrationFilterGroup]
B1 --> B2[Property: string]
B1 --> B3[Operation: Equals/In/DateBefore/DateAfter]
B1 --> B4[Value: object?]
C1 -->|Has many| B1_2[IntegrationFilterRule]
C1 -->|Can contain| C2[IntegrationFilterGroup...]
```
# 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.

View File

@ -2,7 +2,7 @@
using System.Text; using System.Text;
using System.Text.Json; 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.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using RabbitMQ.Client; using RabbitMQ.Client;
@ -20,6 +20,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
private readonly Lazy<Task<IChannel>> _lazyChannel; private readonly Lazy<Task<IChannel>> _lazyChannel;
private readonly IRabbitMqService _rabbitMqService; private readonly IRabbitMqService _rabbitMqService;
private readonly ILogger<RabbitMqIntegrationListenerService> _logger; private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
private readonly TimeProvider _timeProvider;
public RabbitMqIntegrationListenerService(IIntegrationHandler handler, public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
string routingKey, string routingKey,
@ -27,7 +28,8 @@ public class RabbitMqIntegrationListenerService : BackgroundService
string retryQueueName, string retryQueueName,
int maxRetries, int maxRetries,
IRabbitMqService rabbitMqService, IRabbitMqService rabbitMqService,
ILogger<RabbitMqIntegrationListenerService> logger) ILogger<RabbitMqIntegrationListenerService> logger,
TimeProvider timeProvider)
{ {
_handler = handler; _handler = handler;
_routingKey = routingKey; _routingKey = routingKey;
@ -35,6 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
_queueName = queueName; _queueName = queueName;
_rabbitMqService = rabbitMqService; _rabbitMqService = rabbitMqService;
_logger = logger; _logger = logger;
_timeProvider = timeProvider;
_maxRetries = maxRetries; _maxRetries = maxRetries;
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync()); _lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
} }
@ -74,7 +77,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json); var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
if (integrationMessage is not null && if (integrationMessage is not null &&
integrationMessage.DelayUntilDate.HasValue && integrationMessage.DelayUntilDate.HasValue &&
integrationMessage.DelayUntilDate.Value > DateTime.UtcNow) integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
{ {
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea); await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);

View File

@ -1,7 +1,7 @@
#nullable enable #nullable enable
using System.Text; using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Settings; using Bit.Core.Settings;
using RabbitMQ.Client; using RabbitMQ.Client;

View File

@ -1,6 +1,6 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;
@ -11,9 +11,9 @@ public class SlackIntegrationHandler(
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message) public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{ {
await slackService.SendSlackMessageByChannelIdAsync( await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.token, message.Configuration.Token,
message.RenderedTemplate, message.RenderedTemplate,
message.Configuration.channelId message.Configuration.ChannelId
); );
return new IntegrationHandlerResult(success: true, message: message); return new IntegrationHandlerResult(success: true, message: message);

View File

@ -2,14 +2,17 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Text; using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
#nullable enable #nullable enable
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) public class WebhookIntegrationHandler(
IHttpClientFactory httpClientFactory,
TimeProvider timeProvider)
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails> : IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
@ -18,8 +21,16 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message) public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{ {
var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Url);
var response = await _httpClient.PostAsync(message.Configuration.url, content); request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(message.Configuration.Scheme))
{
request.Headers.Authorization = new AuthenticationHeaderValue(
scheme: message.Configuration.Scheme,
parameter: message.Configuration.Token
);
}
var response = await _httpClient.SendAsync(request);
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
switch (response.StatusCode) switch (response.StatusCode)
@ -39,7 +50,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
if (int.TryParse(value, out var seconds)) if (int.TryParse(value, out var seconds))
{ {
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of 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, else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123 "r", // "r" is the round-trip format: RFC1123

View File

@ -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.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -12,6 +11,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
@ -98,7 +98,7 @@ public class OrganizationService : IOrganizationService
IPricingClient pricingClient, IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
) )
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -199,6 +199,7 @@ public class OrganizationService : IOrganizationService
{ {
await AdjustSeatsAsync(organization, seatAdjustment); await AdjustSeatsAsync(organization, seatAdjustment);
} }
if (maxAutoscaleSeats != organization.MaxAutoscaleSeats) if (maxAutoscaleSeats != organization.MaxAutoscaleSeats)
{ {
await UpdateAutoscalingAsync(organization, maxAutoscaleSeats); await UpdateAutoscalingAsync(organization, maxAutoscaleSeats);
@ -207,7 +208,6 @@ public class OrganizationService : IOrganizationService
private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats) private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats)
{ {
if (maxAutoscaleSeats.HasValue && if (maxAutoscaleSeats.HasValue &&
organization.Seats.HasValue && organization.Seats.HasValue &&
maxAutoscaleSeats.Value < organization.Seats.Value) maxAutoscaleSeats.Value < organization.Seats.Value)
@ -229,7 +229,8 @@ public class OrganizationService : IOrganizationService
if (plan.PasswordManager.MaxSeats.HasValue && maxAutoscaleSeats.HasValue && if (plan.PasswordManager.MaxSeats.HasValue && maxAutoscaleSeats.HasValue &&
maxAutoscaleSeats > plan.PasswordManager.MaxSeats) maxAutoscaleSeats > plan.PasswordManager.MaxSeats)
{ {
throw new BadRequestException(string.Concat($"Your plan has a seat limit of {plan.PasswordManager.MaxSeats}, ", throw new BadRequestException(string.Concat(
$"Your plan has a seat limit of {plan.PasswordManager.MaxSeats}, ",
$"but you have specified a max autoscale count of {maxAutoscaleSeats}.", $"but you have specified a max autoscale count of {maxAutoscaleSeats}.",
"Reduce your max autoscale seat count.")); "Reduce your max autoscale seat count."));
} }
@ -250,7 +251,8 @@ public class OrganizationService : IOrganizationService
return await AdjustSeatsAsync(organization, seatAdjustment); return await AdjustSeatsAsync(organization, seatAdjustment);
} }
private async Task<string> AdjustSeatsAsync(Organization organization, int seatAdjustment, IEnumerable<string> ownerEmails = null) private async Task<string> AdjustSeatsAsync(Organization organization, int seatAdjustment,
IEnumerable<string> ownerEmails = null)
{ {
if (organization.Seats == null) if (organization.Seats == null)
{ {
@ -286,10 +288,11 @@ public class OrganizationService : IOrganizationService
} }
var additionalSeats = newSeatTotal - plan.PasswordManager.BaseSeats; var additionalSeats = newSeatTotal - plan.PasswordManager.BaseSeats;
if (plan.PasswordManager.MaxAdditionalSeats.HasValue && additionalSeats > plan.PasswordManager.MaxAdditionalSeats.Value) if (plan.PasswordManager.MaxAdditionalSeats.HasValue &&
additionalSeats > plan.PasswordManager.MaxAdditionalSeats.Value)
{ {
throw new BadRequestException($"Organization plan allows a maximum of " + throw new BadRequestException($"Organization plan allows a maximum of " +
$"{plan.PasswordManager.MaxAdditionalSeats.Value} additional seats."); $"{plan.PasswordManager.MaxAdditionalSeats.Value} additional seats.");
} }
if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal)
@ -300,8 +303,9 @@ public class OrganizationService : IOrganizationService
{ {
if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0) if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0)
{ {
throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " + throw new BadRequestException(
$"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships."); $"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
$"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
} }
else else
{ {
@ -320,7 +324,8 @@ public class OrganizationService : IOrganizationService
organization.Seats = (short?)newSeatTotal; organization.Seats = (short?)newSeatTotal;
await ReplaceAndUpdateCacheAsync(organization); await ReplaceAndUpdateCacheAsync(organization);
if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && organization.Seats == organization.MaxAutoscaleSeats) if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue &&
organization.Seats == organization.MaxAutoscaleSeats)
{ {
try try
{ {
@ -329,7 +334,9 @@ public class OrganizationService : IOrganizationService
ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); OrganizationUserType.Owner)).Select(u => u.Email).Distinct();
} }
await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSeats.Value, ownerEmails);
await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
organization.MaxAutoscaleSeats.Value, ownerEmails);
} }
catch (Exception e) catch (Exception e)
{ {
@ -363,7 +370,7 @@ public class OrganizationService : IOrganizationService
} }
var bankAccount = customer.Sources var bankAccount = customer.Sources
.FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount;
if (bankAccount == null) if (bankAccount == null)
{ {
throw new GatewayException("Cannot find an unverified bank account."); throw new GatewayException("Cannot find an unverified bank account.");
@ -390,7 +397,7 @@ public class OrganizationService : IOrganizationService
if (anySingleOrgPolicies) if (anySingleOrgPolicies)
{ {
throw new BadRequestException("You may not create an organization. You belong to an organization " + throw new BadRequestException("You may not create an organization. You belong to an organization " +
"which has a policy that prohibits you from being a member of any other organization."); "which has a policy that prohibits you from being a member of any other organization.");
} }
} }
@ -404,7 +411,7 @@ public class OrganizationService : IOrganizationService
if (license.LicenseType != LicenseType.Organization) if (license.LicenseType != LicenseType.Organization)
{ {
throw new BadRequestException("Premium licenses cannot be applied to an organization. " + throw new BadRequestException("Premium licenses cannot be applied to an organization. " +
"Upload this license from your personal account settings page."); "Upload this license from your personal account settings page.");
} }
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
@ -423,50 +430,11 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(owner.Id); await ValidateSignUpPoliciesAsync(owner.Id);
var organization = new Organization var organization = claimsPrincipal != null
{ // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization.
Name = license.Name, ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey)
BillingEmail = license.BillingEmail, // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization.
BusinessName = license.BusinessName, : OrganizationFactory.Create(owner, license, publicKey, privateKey);
PlanType = license.PlanType,
Seats = license.Seats,
MaxCollections = license.MaxCollections,
MaxStorageGb = _globalSettings.SelfHosted ? 10240 : license.MaxStorageGb, // 10 TB
UsePolicies = license.UsePolicies,
UseSso = license.UseSso,
UseKeyConnector = license.UseKeyConnector,
UseScim = license.UseScim,
UseGroups = license.UseGroups,
UseDirectory = license.UseDirectory,
UseEvents = license.UseEvents,
UseTotp = license.UseTotp,
Use2fa = license.Use2fa,
UseApi = license.UseApi,
UseResetPassword = license.UseResetPassword,
Plan = license.Plan,
SelfHost = license.SelfHost,
UsersGetPremium = license.UsersGetPremium,
UseCustomPermissions = license.UseCustomPermissions,
Gateway = null,
GatewayCustomerId = null,
GatewaySubscriptionId = null,
ReferenceData = owner.ReferenceData,
Enabled = license.Enabled,
ExpirationDate = license.Expires,
LicenseKey = license.LicenseKey,
PublicKey = publicKey,
PrivateKey = privateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,
UsePasswordManager = license.UsePasswordManager,
UseSecretsManager = license.UseSecretsManager,
SmSeats = license.SmSeats,
SmServiceAccounts = license.SmServiceAccounts,
UseRiskInsights = license.UseRiskInsights,
UseOrganizationDomains = license.UseOrganizationDomains,
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
};
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
@ -481,8 +449,9 @@ public class OrganizationService : IOrganizationService
/// Private helper method to create a new organization. /// Private helper method to create a new organization.
/// This is common code used by both the cloud and self-hosted methods. /// This is common code used by both the cloud and self-hosted methods.
/// </summary> /// </summary>
private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(Organization organization, private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)>
Guid ownerId, string ownerKey, string collectionName, bool withPayment) SignUpAsync(Organization organization,
Guid ownerId, string ownerKey, string collectionName, bool withPayment)
{ {
try try
{ {
@ -538,7 +507,15 @@ public class OrganizationService : IOrganizationService
if (orgUser != null) if (orgUser != null)
{ {
defaultOwnerAccess = defaultOwnerAccess =
[new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; [
new CollectionAccessSelection
{
Id = orgUser.Id,
HidePasswords = false,
ReadOnly = false,
Manage = true
}
];
} }
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
@ -574,7 +551,8 @@ public class OrganizationService : IOrganizationService
} }
} }
public async Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated) public async Task UpdateAsync(Organization organization, bool updateBilling = false,
EventType eventType = EventType.Organization_Updated)
{ {
if (organization.Id == default(Guid)) if (organization.Id == default(Guid))
{ {
@ -595,11 +573,12 @@ public class OrganizationService : IOrganizationService
if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{ {
var customerService = new CustomerService(); var customerService = new CustomerService();
await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions await customerService.UpdateAsync(organization.GatewayCustomerId,
{ new CustomerUpdateOptions
Email = organization.BillingEmail, {
Description = organization.DisplayBusinessName() Email = organization.BillingEmail,
}); Description = organization.DisplayBusinessName()
});
} }
if (eventType == EventType.Organization_CollectionManagement_Updated) if (eventType == EventType.Organization_CollectionManagement_Updated)
@ -649,7 +628,8 @@ public class OrganizationService : IOrganizationService
await UpdateAsync(organization); await UpdateAsync(organization);
} }
public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, public async Task<OrganizationUser> InviteUserAsync(Guid organizationId, Guid? invitingUserId,
EventSystemUser? systemUser,
OrganizationUserInvite invite, string externalId) OrganizationUserInvite invite, string externalId)
{ {
// Ideally OrganizationUserInvite should represent a single user so that this doesn't have to be a runtime check // Ideally OrganizationUserInvite should represent a single user so that this doesn't have to be a runtime check
@ -662,7 +642,8 @@ public class OrganizationService : IOrganizationService
var invalidAssociations = invite.Collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); var invalidAssociations = invite.Collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords));
if (invalidAssociations?.Any() ?? false) if (invalidAssociations?.Any() ?? false)
{ {
throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); throw new BadRequestException(
"The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.");
} }
var results = await InviteUsersAsync(organizationId, invitingUserId, systemUser, var results = await InviteUsersAsync(organizationId, invitingUserId, systemUser,
@ -673,6 +654,7 @@ public class OrganizationService : IOrganizationService
{ {
throw new BadRequestException("This user has already been invited."); throw new BadRequestException("This user has already been invited.");
} }
return result; return result;
} }
@ -684,7 +666,8 @@ public class OrganizationService : IOrganizationService
/// <param name="systemUser">The system user which is sending the invite. Only used when inviting via SCIM; null if using a client app or Public API</param> /// <param name="systemUser">The system user which is sending the invite. Only used when inviting via SCIM; null if using a client app or Public API</param>
/// <param name="invites">Details about the users being invited</param> /// <param name="invites">Details about the users being invited</param>
/// <returns></returns> /// <returns></returns>
public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, public async Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId,
EventSystemUser? systemUser,
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
{ {
var inviteTypes = new HashSet<OrganizationUserType>(invites.Where(i => i.invite.Type.HasValue) var inviteTypes = new HashSet<OrganizationUserType>(invites.Where(i => i.invite.Type.HasValue)
@ -696,7 +679,8 @@ public class OrganizationService : IOrganizationService
{ {
foreach (var (invite, _) in invites) foreach (var (invite, _) in invites)
{ {
await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null, invite.Permissions); await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null,
invite.Permissions);
await ValidateOrganizationCustomPermissionsEnabledAsync(organizationId, invite.Type.Value); await ValidateOrganizationCustomPermissionsEnabledAsync(organizationId, invite.Type.Value);
} }
} }
@ -706,7 +690,8 @@ public class OrganizationService : IOrganizationService
if (systemUser.HasValue) if (systemUser.HasValue)
{ {
// Log SCIM event // Log SCIM event
await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser.Value, e.Item3))); await _eventService.LogOrganizationUserEventsAsync(events.Select(e =>
(e.Item1, e.Item2, systemUser.Value, e.Item3)));
} }
else else
{ {
@ -717,8 +702,10 @@ public class OrganizationService : IOrganizationService
return organizationUsers; return organizationUsers;
} }
private async Task<(List<OrganizationUser> organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> SaveUsersSendInvitesAsync(Guid organizationId, private async
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) Task<(List<OrganizationUser> organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)>
SaveUsersSendInvitesAsync(Guid organizationId,
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
{ {
var organization = await GetOrgById(organizationId); var organization = await GetOrgById(organizationId);
var initialSeatCount = organization.Seats; var initialSeatCount = organization.Seats;
@ -728,7 +715,8 @@ public class OrganizationService : IOrganizationService
} }
var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync( var existingEmails = new HashSet<string>(await _organizationUserRepository.SelectKnownEmailsAsync(
organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); organizationId, invites.SelectMany(i => i.invite.Emails), false),
StringComparer.InvariantCultureIgnoreCase);
// Seat autoscaling // Seat autoscaling
var initialSmSeatCount = organization.SmSeats; var initialSmSeatCount = organization.SmSeats;
@ -756,7 +744,8 @@ public class OrganizationService : IOrganizationService
.SelectMany(i => i.invite.Emails) .SelectMany(i => i.invite.Emails)
.Count(email => !existingEmails.Contains(email)); .Count(email => !existingEmails.Contains(email));
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount); var additionalSmSeatsRequired =
await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount);
if (additionalSmSeatsRequired > 0) if (additionalSmSeatsRequired > 0)
{ {
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
@ -765,7 +754,9 @@ public class OrganizationService : IOrganizationService
} }
var invitedAreAllOwners = invites.All(i => i.invite.Type == OrganizationUserType.Owner); var invitedAreAllOwners = invites.All(i => i.invite.Type == OrganizationUserType.Owner);
if (!invitedAreAllOwners && !await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { }, includeProvider: true)) if (!invitedAreAllOwners &&
!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { },
includeProvider: true))
{ {
throw new BadRequestException("Organization must have at least one confirmed owner."); throw new BadRequestException("Organization must have at least one confirmed owner.");
} }
@ -888,7 +879,8 @@ public class OrganizationService : IOrganizationService
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
} }
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value) if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue &&
currentOrganization.Seats.Value != initialSeatCount.Value)
{ {
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value); await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
} }
@ -904,10 +896,13 @@ public class OrganizationService : IOrganizationService
return (allOrgUsers, events); return (allOrgUsers, events);
} }
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId,
Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId) IEnumerable<Guid> organizationUsersId)
{ {
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
_logger.LogUserInviteStateDiagnostics(orgUsers);
var org = await GetOrgById(organizationId); var org = await GetOrgById(organizationId);
var result = new List<Tuple<OrganizationUser, string>>(); var result = new List<Tuple<OrganizationUser, string>>();
@ -926,7 +921,8 @@ public class OrganizationService : IOrganizationService
return result; return result;
} }
public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false) public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId,
bool initOrganization = false)
{ {
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (orgUser == null || orgUser.OrganizationId != organizationId || if (orgUser == null || orgUser.OrganizationId != organizationId ||
@ -935,6 +931,8 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("User invalid."); throw new BadRequestException("User invalid.");
} }
_logger.LogUserInviteStateDiagnostics(orgUser);
var org = await GetOrgById(orgUser.OrganizationId); var org = await GetOrgById(orgUser.OrganizationId);
await SendInviteAsync(orgUser, org, initOrganization); await SendInviteAsync(orgUser, org, initOrganization);
} }
@ -1013,7 +1011,9 @@ public class OrganizationService : IOrganizationService
IEnumerable<string> ownerEmails; IEnumerable<string> ownerEmails;
if (providerOrg != null) if (providerOrg != null)
{ {
ownerEmails = (await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId, ProviderUserStatusType.Confirmed)) ownerEmails =
(await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId,
ProviderUserStatusType.Confirmed))
.Select(u => u.Email).Distinct(); .Select(u => u.Email).Distinct();
} }
else else
@ -1021,6 +1021,7 @@ public class OrganizationService : IOrganizationService
ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); OrganizationUserType.Owner)).Select(u => u.Email).Distinct();
} }
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
await AdjustSeatsAsync(organization, seatsToAdd, ownerEmails); await AdjustSeatsAsync(organization, seatsToAdd, ownerEmails);
@ -1035,8 +1036,8 @@ public class OrganizationService : IOrganizationService
} }
public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey,
public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId) Guid? callingUserId)
{ {
// Org User must be the same as the calling user and the organization ID associated with the user must match passed org ID // Org User must be the same as the calling user and the organization ID associated with the user must match passed org ID
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
@ -1064,30 +1065,35 @@ public class OrganizationService : IOrganizationService
// Block the user from withdrawal if auto enrollment is enabled // Block the user from withdrawal if auto enrollment is enabled
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{ {
var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(userId); var resetPasswordPolicyRequirement =
await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(userId);
if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId)) if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId))
{ {
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); throw new BadRequestException(
"Due to an Enterprise Policy, you are not allowed to withdraw from account recovery.");
} }
} }
else else
{ {
if (resetPasswordKey == null && resetPasswordPolicy.Data != null) if (resetPasswordKey == null && resetPasswordPolicy.Data != null)
{ {
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data,
JsonHelpers.IgnoreCase);
if (data?.AutoEnrollEnabled ?? false) if (data?.AutoEnrollEnabled ?? false)
{ {
throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); throw new BadRequestException(
"Due to an Enterprise Policy, you are not allowed to withdraw from account recovery.");
} }
} }
} }
orgUser.ResetPasswordKey = resetPasswordKey; orgUser.ResetPasswordKey = resetPasswordKey;
await _organizationUserRepository.ReplaceAsync(orgUser); await _organizationUserRepository.ReplaceAsync(orgUser);
await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? await _eventService.LogOrganizationUserEventAsync(orgUser,
EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw); resetPasswordKey != null
? EventType.OrganizationUser_ResetPassword_Enroll
: EventType.OrganizationUser_ResetPassword_Withdraw);
} }
public async Task ImportAsync(Guid organizationId, public async Task ImportAsync(Guid organizationId,
@ -1124,15 +1130,16 @@ public class OrganizationService : IOrganizationService
var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId);
var removeUsersSet = new HashSet<string>(removeUserExternalIds) var removeUsersSet = new HashSet<string>(removeUserExternalIds)
.Except(newUsersSet) .Except(newUsersSet)
.Where(u => existingUsersDict.TryGetValue(u, out var existingUser) && existingUser.Type != OrganizationUserType.Owner) .Where(u => existingUsersDict.TryGetValue(u, out var existingUser) &&
existingUser.Type != OrganizationUserType.Owner)
.Select(u => existingUsersDict[u]); .Select(u => existingUsersDict[u]);
await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id));
events.AddRange(removeUsersSet.Select(u => ( events.AddRange(removeUsersSet.Select(u => (
u, u,
EventType.OrganizationUser_Removed, EventType.OrganizationUser_Removed,
(DateTime?)DateTime.UtcNow (DateTime?)DateTime.UtcNow
)) ))
); );
} }
@ -1145,10 +1152,10 @@ public class OrganizationService : IOrganizationService
existingExternalUsersIdDict.ContainsKey(u.ExternalId)); existingExternalUsersIdDict.ContainsKey(u.ExternalId));
await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id));
events.AddRange(usersToDelete.Select(u => ( events.AddRange(usersToDelete.Select(u => (
u, u,
EventType.OrganizationUser_Removed, EventType.OrganizationUser_Removed,
(DateTime?)DateTime.UtcNow (DateTime?)DateTime.UtcNow
)) ))
); );
foreach (var deletedUser in usersToDelete) foreach (var deletedUser in usersToDelete)
{ {
@ -1176,6 +1183,7 @@ public class OrganizationService : IOrganizationService
existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id);
} }
} }
await _organizationUserRepository.UpsertManyAsync(usersToUpsert); await _organizationUserRepository.UpsertManyAsync(usersToUpsert);
// Add new users // Add new users
@ -1186,7 +1194,8 @@ public class OrganizationService : IOrganizationService
var enoughSeatsAvailable = true; var enoughSeatsAvailable = true;
if (organization.Seats.HasValue) if (organization.Seats.HasValue)
{ {
var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var seatCounts =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
seatsAvailable = organization.Seats.Value - seatCounts.Total; seatsAvailable = organization.Seats.Value - seatCounts.Total;
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
} }
@ -1219,7 +1228,8 @@ public class OrganizationService : IOrganizationService
} }
} }
var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser, userInvites); var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser,
userInvites);
foreach (var invitedUser in invitedUsers) foreach (var invitedUser in invitedUsers)
{ {
existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id);
@ -1256,7 +1266,8 @@ public class OrganizationService : IOrganizationService
} }
await _eventService.LogGroupEventsAsync( await _eventService.LogGroupEventsAsync(
savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser,
(DateTime?)DateTime.UtcNow)));
var updateGroups = existingExternalGroups var updateGroups = existingExternalGroups
.Where(g => groupsDict.ContainsKey(g.ExternalId)) .Where(g => groupsDict.ContainsKey(g.ExternalId))
@ -1283,11 +1294,11 @@ public class OrganizationService : IOrganizationService
await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds,
existingExternalUsersIdDict, existingExternalUsersIdDict,
existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null);
} }
await _eventService.LogGroupEventsAsync( await _eventService.LogGroupEventsAsync(
updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser,
(DateTime?)DateTime.UtcNow)));
} }
} }
@ -1299,10 +1310,12 @@ public class OrganizationService : IOrganizationService
await _ssoUserRepository.DeleteAsync(userId, organizationId); await _ssoUserRepository.DeleteAsync(userId, organizationId);
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId); var organizationUser =
await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId);
if (organizationUser != null) if (organizationUser != null)
{ {
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UnlinkedSso); await _eventService.LogOrganizationUserEventAsync(organizationUser,
EventType.OrganizationUser_UnlinkedSso);
} }
} }
} }
@ -1424,7 +1437,7 @@ public class OrganizationService : IOrganizationService
} }
if ((plan.ProductTier == ProductTierType.TeamsStarter && if ((plan.ProductTier == ProductTierType.TeamsStarter &&
upgrade.AdditionalSmSeats.GetValueOrDefault() > plan.PasswordManager.BaseSeats) || upgrade.AdditionalSmSeats.GetValueOrDefault() > plan.PasswordManager.BaseSeats) ||
(plan.ProductTier != ProductTierType.TeamsStarter && (plan.ProductTier != ProductTierType.TeamsStarter &&
upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats)) upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats))
{ {
@ -1447,7 +1460,8 @@ public class OrganizationService : IOrganizationService
} }
} }
public async Task ValidateOrganizationUserUpdatePermissions(Guid organizationId, OrganizationUserType newType, OrganizationUserType? oldType, Permissions permissions) public async Task ValidateOrganizationUserUpdatePermissions(Guid organizationId, OrganizationUserType newType,
OrganizationUserType? oldType, Permissions permissions)
{ {
if (await _currentContext.OrganizationOwner(organizationId)) if (await _currentContext.OrganizationOwner(organizationId))
{ {
@ -1474,13 +1488,15 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("Custom users can not manage Admins or Owners."); throw new BadRequestException("Custom users can not manage Admins or Owners.");
} }
if (newType == OrganizationUserType.Custom && !await ValidateCustomPermissionsGrant(organizationId, permissions)) if (newType == OrganizationUserType.Custom &&
!await ValidateCustomPermissionsGrant(organizationId, permissions))
{ {
throw new BadRequestException("Custom users can only grant the same custom permissions that they have."); throw new BadRequestException("Custom users can only grant the same custom permissions that they have.");
} }
} }
public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId, OrganizationUserType newType) public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId,
OrganizationUserType newType)
{ {
if (newType != OrganizationUserType.Custom) if (newType != OrganizationUserType.Custom)
{ {
@ -1495,7 +1511,8 @@ public class OrganizationService : IOrganizationService
if (!organization.UseCustomPermissions) if (!organization.UseCustomPermissions)
{ {
throw new BadRequestException("To enable custom permissions the organization must be on an Enterprise plan."); throw new BadRequestException(
"To enable custom permissions the organization must be on an Enterprise plan.");
} }
} }
@ -1601,7 +1618,8 @@ public class OrganizationService : IOrganizationService
EventSystemUser systemUser) EventSystemUser systemUser)
{ {
await RepositoryRevokeUserAsync(organizationUser); await RepositoryRevokeUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked,
systemUser);
if (organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
{ {
@ -1616,7 +1634,8 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("Already revoked."); throw new BadRequestException("Already revoked.");
} }
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, new[] { organizationUser.Id }, includeProvider: true)) if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId,
new[] { organizationUser.Id }, includeProvider: true))
{ {
throw new BadRequestException("Organization must have at least one confirmed owner."); throw new BadRequestException("Organization must have at least one confirmed owner.");
} }
@ -1664,7 +1683,8 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("You cannot revoke yourself."); throw new BadRequestException("You cannot revoke yourself.");
} }
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && !deletingUserIsOwner) if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
!deletingUserIsOwner)
{ {
throw new BadRequestException("Only owners can revoke other owners."); throw new BadRequestException("Only owners can revoke other owners.");
} }
@ -1705,27 +1725,4 @@ public class OrganizationService : IOrganizationService
return status; 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);
}
} }

View File

@ -0,0 +1,114 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.AdminConsole.Services;
public static class OrganizationFactory
{
public static Organization Create(
User owner,
ClaimsPrincipal claimsPrincipal,
string publicKey,
string privateKey) => new()
{
Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name),
BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail),
BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName),
PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType),
Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats),
MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections),
MaxStorageGb = 10240,
UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies),
UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso),
UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector),
UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim),
UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups),
UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory),
UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents),
UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp),
Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa),
UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi),
UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword),
Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan),
SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost),
UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium),
UseCustomPermissions =
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions),
Gateway = null,
GatewayCustomerId = null,
GatewaySubscriptionId = null,
ReferenceData = owner.ReferenceData,
Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled),
ExpirationDate = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires),
LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey),
PublicKey = publicKey,
PrivateKey = privateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,
UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager),
UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager),
SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats),
SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts),
UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights),
UseOrganizationDomains =
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains),
UseAdminSponsoredFamilies =
claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies),
};
public static Organization Create(
User owner,
OrganizationLicense license,
string publicKey,
string privateKey) => new()
{
Name = license.Name,
BillingEmail = license.BillingEmail,
BusinessName = license.BusinessName,
PlanType = license.PlanType,
Seats = license.Seats,
MaxCollections = license.MaxCollections,
MaxStorageGb = 10240,
UsePolicies = license.UsePolicies,
UseSso = license.UseSso,
UseKeyConnector = license.UseKeyConnector,
UseScim = license.UseScim,
UseGroups = license.UseGroups,
UseDirectory = license.UseDirectory,
UseEvents = license.UseEvents,
UseTotp = license.UseTotp,
Use2fa = license.Use2fa,
UseApi = license.UseApi,
UseResetPassword = license.UseResetPassword,
Plan = license.Plan,
SelfHost = license.SelfHost,
UsersGetPremium = license.UsersGetPremium,
UseCustomPermissions = license.UseCustomPermissions,
Gateway = null,
GatewayCustomerId = null,
GatewaySubscriptionId = null,
ReferenceData = owner.ReferenceData,
Enabled = license.Enabled,
ExpirationDate = license.Expires,
LicenseKey = license.LicenseKey,
PublicKey = publicKey,
PrivateKey = privateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,
UsePasswordManager = license.UsePasswordManager,
UseSecretsManager = license.UseSecretsManager,
SmSeats = license.SmSeats,
SmServiceAccounts = license.SmServiceAccounts,
UseRiskInsights = license.UseRiskInsights,
UseOrganizationDomains = license.UseOrganizationDomains,
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
};
}

View File

@ -0,0 +1,67 @@
using System.Text.Json;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.Extensions.Logging;
using Quartz.Util;
namespace Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
/// <summary>
/// Temporary code: Log warning when OrganizationUser is in an invalid state,
/// so we can identify which flow is causing the issue through Datadog.
/// </summary>
public static class UserInviteDebuggingLogger
{
public static void LogUserInviteStateDiagnostics(this ILogger logger, OrganizationUser orgUser)
{
LogUserInviteStateDiagnostics(logger, [orgUser]);
}
public static void LogUserInviteStateDiagnostics(this ILogger logger, IEnumerable<OrganizationUser> allOrgUsers)
{
try
{
var invalidInviteState = allOrgUsers.Any(user => user.Status == OrganizationUserStatusType.Invited && user.Email.IsNullOrWhiteSpace());
if (invalidInviteState)
{
var logData = MapObjectDataToLog(allOrgUsers);
logger.LogWarning("Warning invalid invited state. {logData}", logData);
}
var invalidConfirmedOrAcceptedState = allOrgUsers.Any(user => (user.Status == OrganizationUserStatusType.Confirmed || user.Status == OrganizationUserStatusType.Accepted) && !user.Email.IsNullOrWhiteSpace());
if (invalidConfirmedOrAcceptedState)
{
var logData = MapObjectDataToLog(allOrgUsers);
logger.LogWarning("Warning invalid confirmed or accepted state. {logData}", logData);
}
}
catch (Exception exception)
{
// Ensure that this debugging instrument does not interfere with the current flow.
logger.LogWarning(exception, "Unexpected exception from UserInviteDebuggingLogger");
}
}
private static string MapObjectDataToLog(IEnumerable<OrganizationUser> allOrgUsers)
{
var log = allOrgUsers.Select(allOrgUser => new
{
allOrgUser.OrganizationId,
allOrgUser.Status,
hasEmail = !allOrgUser.Email.IsNullOrWhiteSpace(),
userId = allOrgUser.UserId,
allOrgUserId = allOrgUser.Id
});
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
return JsonSerializer.Serialize(log, options);
}
}

View File

@ -0,0 +1,83 @@

using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Enums;
namespace Bit.Core.Auth.Models.Data;
public class PendingAuthRequestDetails : AuthRequest
{
public Guid? RequestDeviceId { get; set; }
/**
* Constructor for EF response.
*/
public PendingAuthRequestDetails(
AuthRequest authRequest,
Guid? deviceId)
{
ArgumentNullException.ThrowIfNull(authRequest);
Id = authRequest.Id;
UserId = authRequest.UserId;
OrganizationId = authRequest.OrganizationId;
Type = authRequest.Type;
RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier;
RequestDeviceType = authRequest.RequestDeviceType;
RequestIpAddress = authRequest.RequestIpAddress;
RequestCountryName = authRequest.RequestCountryName;
ResponseDeviceId = authRequest.ResponseDeviceId;
AccessCode = authRequest.AccessCode;
PublicKey = authRequest.PublicKey;
Key = authRequest.Key;
MasterPasswordHash = authRequest.MasterPasswordHash;
Approved = authRequest.Approved;
CreationDate = authRequest.CreationDate;
ResponseDate = authRequest.ResponseDate;
AuthenticationDate = authRequest.AuthenticationDate;
RequestDeviceId = deviceId;
}
/**
* Constructor for dapper response.
*/
public PendingAuthRequestDetails(
Guid id,
Guid userId,
Guid organizationId,
short type,
string requestDeviceIdentifier,
short requestDeviceType,
string requestIpAddress,
string requestCountryName,
Guid? responseDeviceId,
string accessCode,
string publicKey,
string key,
string masterPasswordHash,
bool? approved,
DateTime creationDate,
DateTime? responseDate,
DateTime? authenticationDate,
Guid deviceId)
{
Id = id;
UserId = userId;
OrganizationId = organizationId;
Type = (AuthRequestType)type;
RequestDeviceIdentifier = requestDeviceIdentifier;
RequestDeviceType = (DeviceType)requestDeviceType;
RequestIpAddress = requestIpAddress;
RequestCountryName = requestCountryName;
ResponseDeviceId = responseDeviceId;
AccessCode = accessCode;
PublicKey = publicKey;
Key = key;
MasterPasswordHash = masterPasswordHash;
Approved = approved;
CreationDate = creationDate;
ResponseDate = responseDate;
AuthenticationDate = authenticationDate;
RequestDeviceId = deviceId;
}
}

View File

@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
{ {
Task<int> DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration); Task<int> DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration);
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId); Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
/// <summary>
/// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different
/// device. It will be the most current request for the device.
/// </summary>
/// <param name="userId">UserId of the owner of the AuthRequests</param>
/// <returns>a collection Auth request details or empty</returns>
Task<IEnumerable<PendingAuthRequestDetails>> GetManyPendingAuthRequestByUserId(Guid userId);
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids); Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests); Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);

View File

@ -1,5 +1,9 @@
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Exceptions;
using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Models.Api.Request.AuthRequest;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Settings;
#nullable enable #nullable enable
@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services;
public interface IAuthRequestService public interface IAuthRequestService
{ {
Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId); /// <summary>
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code); /// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches
/// userId. Returns null if the user doesn't match or if the AuthRequest is not found.
/// </summary>
/// <param name="authRequestId">Authrequest Id being fetched</param>
/// <param name="userId">user who owns AuthRequest</param>
/// <returns>An AuthRequest or null</returns>
Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId);
/// <summary>
/// Fetches the authrequest from the database with the id provided. Then checks
/// the accessCode against the AuthRequest.AccessCode from the database. accessCodes
/// must match the found authRequest, and the AuthRequest must not be expired. Expiration
/// is configured in <see cref="GlobalSettings"/>
/// </summary>
/// <param name="authRequestId">AuthRequest being acted on</param>
/// <param name="accessCode">Access code of the authrequest, must match saved database value</param>
/// <returns>A valid AuthRequest or null</returns>
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode);
/// <summary>
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
/// </summary>
/// <remarks>
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
/// </remarks>
Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model); Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model);
/// <summary>
/// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves
/// or rejects the login request.
/// </summary>
/// <param name="authRequestId">AuthRequest being acted on.</param>
/// <param name="userId">User acting on AuthRequest</param>
/// <param name="model">Update context for the AuthRequest</param>
/// <returns>retuns an AuthRequest or throws an exception</returns>
/// <exception cref="DuplicateAuthRequestException">Thows if the AuthRequest has already been Approved/Rejected</exception>
/// <exception cref="NotFoundException">Throws if the AuthRequest as expired or the userId doesn't match</exception>
/// <exception cref="BadRequestException">Throws if the device isn't associated with the UserId</exception>
Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model); Task<AuthRequest> UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model);
} }

View File

@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService
_logger = logger; _logger = logger;
} }
public async Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId) public async Task<AuthRequest?> GetAuthRequestAsync(Guid authRequestId, Guid userId)
{ {
var authRequest = await _authRequestRepository.GetByIdAsync(id); var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
if (authRequest == null || authRequest.UserId != userId) if (authRequest == null || authRequest.UserId != userId)
{ {
return null; return null;
@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService
return authRequest; return authRequest;
} }
public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code) public async Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode)
{ {
var authRequest = await _authRequestRepository.GetByIdAsync(id); var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code)) if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
{ {
return null; return null;
} }
@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService
return authRequest; return authRequest;
} }
/// <summary>
/// Validates and Creates an <see cref="AuthRequest" /> in the database, as well as pushes it through notifications services
/// </summary>
/// <remarks>
/// This method can only be called inside of an HTTP call because of it's reliance on <see cref="ICurrentContext" />
/// </remarks>
public async Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model) public async Task<AuthRequest> CreateAuthRequestAsync(AuthRequestCreateRequestModel model)
{ {
if (!_currentContext.DeviceType.HasValue) if (!_currentContext.DeviceType.HasValue)

View File

@ -38,7 +38,6 @@ public static class UserServiceCollectionExtensions
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
{ {
services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>();
services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>(); services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>();
} }

View File

@ -34,6 +34,7 @@ public class OrganizationSale
var subscriptionSetup = GetSubscriptionSetup(signup); var subscriptionSetup = GetSubscriptionSetup(signup);
subscriptionSetup.SkipTrial = signup.SkipTrial; subscriptionSetup.SkipTrial = signup.SkipTrial;
subscriptionSetup.InitiationPath = signup.InitiationPath;
return new OrganizationSale return new OrganizationSale
{ {

View File

@ -10,6 +10,7 @@ public class SubscriptionSetup
public required PasswordManager PasswordManagerOptions { get; set; } public required PasswordManager PasswordManagerOptions { get; set; }
public SecretsManager? SecretsManagerOptions { get; set; } public SecretsManager? SecretsManagerOptions { get; set; }
public bool SkipTrial = false; public bool SkipTrial = false;
public string? InitiationPath { get; set; }
public class PasswordManager public class PasswordManager
{ {

View File

@ -420,12 +420,29 @@ public class OrganizationBillingService(
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
["organizationId"] = organizationId.ToString() ["organizationId"] = organizationId.ToString(),
["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) &&
subscriptionSetup.InitiationPath.Contains("trial from marketing website")
? "marketing-initiated"
: "product-initiated"
}, },
OffSession = true, OffSession = true,
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays 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 = var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);

View File

@ -107,11 +107,10 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; 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 LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PolicyRequirements = "pm-14439-policy-requirements"; 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 ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; 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 SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
/* Auth Team */ /* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
@ -138,6 +137,7 @@ public static class FeatureFlagKeys
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string InlineMenuTotp = "inline-menu-totp"; public const string InlineMenuTotp = "inline-menu-totp";
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
/* Billing Team */ /* Billing Team */
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
@ -181,6 +181,7 @@ public static class FeatureFlagKeys
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting"; public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill"; 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 EnablePMPreloginSettings = "enable-pm-prelogin-settings";
public const string AppIntents = "app-intents"; public const string AppIntents = "app-intents";
@ -192,7 +193,6 @@ public static class FeatureFlagKeys
public const string IpcChannelFramework = "ipc-channel-framework"; public const string IpcChannelFramework = "ipc-channel-framework";
/* Tools Team */ /* Tools Team */
public const string ItemShare = "item-share";
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
/* Vault Team */ /* Vault Team */
@ -205,6 +205,8 @@ public static class FeatureFlagKeys
public const string EndUserNotifications = "pm-10609-end-user-notifications"; public const string EndUserNotifications = "pm-10609-end-user-notifications";
public const string PhishingDetection = "phishing-detection"; public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; 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() public static List<string> GetAllKeys()
{ {

View File

@ -21,8 +21,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.79" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.0.10" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.136" /> <PackageReference Include="AWSSDK.SQS" Version="4.0.0.11" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
@ -59,7 +59,7 @@
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.7.0" /> <PackageReference Include="LaunchDarkly.ServerSdk" Version="8.9.1" />
<PackageReference Include="Quartz" Version="3.14.0" /> <PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />

View 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();
}
}

View 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();
}
}

View File

@ -3,7 +3,7 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.Dirt.Reports.Entities; namespace Bit.Core.Dirt.Entities;
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
{ {

View File

@ -1,4 +1,4 @@
namespace Bit.Core.Dirt.Reports.Models.Data; namespace Bit.Core.Dirt.Models.Data;
public class MemberAccessDetails public class MemberAccessDetails
{ {

View 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; }
}

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