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:
@ -3,6 +3,9 @@ services:
|
||||
image: mcr.microsoft.com/devcontainers/dotnet:8.0
|
||||
volumes:
|
||||
- ../../:/workspace:cached
|
||||
env_file:
|
||||
- path: ../../dev/.env
|
||||
required: false
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
|
@ -1,17 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
export DEV_DIR=/workspace/dev
|
||||
export REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev
|
||||
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
get_installation_id_and_key() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
echo "Please enter your installation id and key from https://bitwarden.com/host:"
|
||||
read -r -p "Installation id: " INSTALLATION_ID
|
||||
read -r -p "Installation key: " INSTALLATION_KEY
|
||||
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
|
||||
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
|
||||
secrets.json.example >secrets.json # create/overwrite secrets.json
|
||||
popd >/dev/null || exit
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
allow_interactive=1
|
||||
else
|
||||
echo "Doing non-interactive setup"
|
||||
allow_interactive=0
|
||||
fi
|
||||
|
||||
get_option() {
|
||||
# Helper function for reading the value of an environment variable
|
||||
# primarily but then falling back to an interactive question if allowed
|
||||
# and lastly falling back to a default value input when either other
|
||||
# option is available.
|
||||
name_of_var="$1"
|
||||
question_text="$2"
|
||||
default_value="$3"
|
||||
is_secret="$4"
|
||||
|
||||
if [[ -n "${!name_of_var}" ]]; then
|
||||
# If the env variable they gave us has a value, then use that value
|
||||
echo "${!name_of_var}"
|
||||
elif [[ "$allow_interactive" == 1 ]]; then
|
||||
# If we can be interactive, then use the text they gave us to request input
|
||||
if [[ "$is_secret" == 1 ]]; then
|
||||
read -r -s -p "$question_text" response
|
||||
echo "$response"
|
||||
else
|
||||
read -r -p "$question_text" response
|
||||
echo "$response"
|
||||
fi
|
||||
else
|
||||
# If no environment variable and not interactive, then just give back default value
|
||||
echo "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_comments() {
|
||||
@ -26,51 +51,70 @@ remove_comments() {
|
||||
|
||||
configure_other_vars() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
cp secrets.json .secrets.json.tmp
|
||||
cp "$REPO_ROOT/dev/secrets.json" "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
# set DB_PASSWORD equal to .services.mssql.environment.MSSQL_SA_PASSWORD, accounting for quotes
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $DEV_DIR/.env)"
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $REPO_ROOT/dev/.env)"
|
||||
SQL_CONNECTION_STRING="Server=localhost;Database=vault_dev;User Id=SA;Password=$DB_PASSWORD;Encrypt=True;TrustServerCertificate=True"
|
||||
jq \
|
||||
".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" |
|
||||
.globalSettings.postgreSql.connectionString = \"Host=localhost;Username=postgres;Password=$DB_PASSWORD;Database=vault_dev;Include Error Detail=true\" |
|
||||
.globalSettings.mySql.connectionString = \"server=localhost;uid=root;pwd=$DB_PASSWORD;database=vault_dev\"" \
|
||||
.secrets.json.tmp >secrets.json
|
||||
rm .secrets.json.tmp
|
||||
rm "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
popd >/dev/null || exit
|
||||
}
|
||||
|
||||
one_time_setup() {
|
||||
if [[ ! -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
# We do not have the cert file
|
||||
if [[ ! -z "${DEV_CERT_CONTENTS}" ]]; then
|
||||
# Make file for them
|
||||
echo "Making $REPO_ROOT/dev/dev.pfx file for you based on DEV_CERT_CONTENTS environment variable."
|
||||
# Assume content is base64 encoded
|
||||
echo "$DEV_CERT_CONTENTS" | base64 -d > "$REPO_ROOT/dev/dev.pfx"
|
||||
else
|
||||
if [[ $allow_interactive -eq 1 ]]; then
|
||||
read -r -p \
|
||||
"Would you like to configure your secrets and certificates for the first time?
|
||||
WARNING: This will overwrite any existing secrets.json and certificate files.
|
||||
Proceed? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running one-time setup script..."
|
||||
sleep 1
|
||||
read -r -p \
|
||||
"Place the secrets.json and dev.pfx files from our shared Collection in the ./dev directory.
|
||||
"Place the dev.pfx files from our shared Collection in the $REPO_ROOT/dev directory.
|
||||
Press <Enter> to continue."
|
||||
remove_comments ./dev/secrets.json
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
cert_password="$(get_option "DEV_CERT_PASSWORD" "Paste the \"Licensing Certificate - Dev\" password: " "" 1)"
|
||||
certificate-tool add --file "$REPO_ROOT/dev/dev.pfx" --password "$cert_password"
|
||||
else
|
||||
echo "You don't have a $REPO_ROOT/dev/dev.pfx file setup." >/dev/stderr
|
||||
fi
|
||||
|
||||
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like us to setup your secrets.json file for you? [y/N] " "n")"
|
||||
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
remove_comments "$REPO_ROOT/dev/secrets.json"
|
||||
configure_other_vars
|
||||
# setup_secrets needs to be ran from the dev folder
|
||||
pushd "$REPO_ROOT/dev" >/dev/null || exit
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh "$REPO_ROOT/dev/setup_secrets.ps1" || true
|
||||
popd >/dev/null || exit
|
||||
fi
|
||||
|
||||
do_azurite_setup="$(get_option "SETUP_AZURITE" "Would you like us to setup your azurite environment? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Installing Az module. This will take ~a minute..."
|
||||
pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force"
|
||||
pwsh ./dev/setup_azurite.ps1
|
||||
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
|
||||
read -r -s -p "Paste the \"Licensing Certificate - Dev\" password: " CERT_PASSWORD
|
||||
echo
|
||||
pushd ./dev >/dev/null || exit
|
||||
certificate-tool add --file ./dev.pfx --password "$CERT_PASSWORD"
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh ./setup_secrets.ps1 || true
|
||||
popd >/dev/null || exit
|
||||
pwsh "$REPO_ROOT/dev/setup_azurite.ps1"
|
||||
fi
|
||||
|
||||
run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running migrations..."
|
||||
sleep 5 # wait for DB container to start
|
||||
dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING"
|
||||
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"
|
||||
fi
|
||||
read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response
|
||||
|
||||
stripe_response="$(get_option "INSTALL_STRIPE_CLI" "Would you like to install the Stripe CLI? [y/N] " "n")"
|
||||
if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
install_stripe_cli
|
||||
fi
|
||||
@ -88,11 +132,4 @@ install_stripe_cli() {
|
||||
sudo apt install -y stripe
|
||||
}
|
||||
|
||||
# main
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
one_time_setup
|
||||
else
|
||||
# Ignore interactive elements when running in codespaces since they are not supported there
|
||||
# TODO Write codespaces specific instructions and link here
|
||||
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
|
||||
fi
|
||||
one_time_setup
|
||||
|
3
.github/workflows/enforce-labels.yml
vendored
3
.github/workflows/enforce-labels.yml
vendored
@ -4,6 +4,9 @@ on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }}
|
||||
|
3
.github/workflows/protect-files.yml
vendored
3
.github/workflows/protect-files.yml
vendored
@ -16,6 +16,9 @@ jobs:
|
||||
changed-files:
|
||||
name: Check for file changes
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
changes: ${{steps.check-changes.outputs.changes_detected}}
|
||||
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -59,6 +59,8 @@ jobs:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download latest release Docker stubs
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
|
202
.github/workflows/repository-management.yml
vendored
202
.github/workflows/repository-management.yml
vendored
@ -22,11 +22,12 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-24.04
|
||||
permissions: {}
|
||||
outputs:
|
||||
branch: ${{ steps.set-branch.outputs.branch }}
|
||||
steps:
|
||||
@ -45,74 +46,12 @@ jobs:
|
||||
|
||||
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:
|
||||
name: Bump Version
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- cut_branch
|
||||
- setup
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
steps:
|
||||
@ -122,30 +61,12 @@ jobs:
|
||||
with:
|
||||
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
|
||||
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 }}
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@ -230,120 +151,45 @@ jobs:
|
||||
- name: Push changes
|
||||
run: git push
|
||||
|
||||
|
||||
cherry_pick:
|
||||
name: Cherry-Pick Commit(s)
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- bump_version
|
||||
- setup
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
- bump_version
|
||||
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 }}
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Check out main branch
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "actions@github.com"
|
||||
git config --local user.name "Github Actions"
|
||||
|
||||
- name: Install xmllint
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-utils
|
||||
|
||||
- name: Perform cherry-pick(s)
|
||||
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||
env:
|
||||
CUT_BRANCH: ${{ needs.setup.outputs.branch }}
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
# Function for cherry-picking
|
||||
cherry_pick () {
|
||||
local source_branch=$1
|
||||
local destination_branch=$2
|
||||
|
||||
# Get project commit/version from source branch
|
||||
git switch $source_branch
|
||||
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
|
||||
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
|
||||
# Get project commit/version from destination branch
|
||||
git switch $destination_branch
|
||||
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||
|
||||
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
|
||||
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
|
||||
git push -u origin $destination_branch
|
||||
fi
|
||||
}
|
||||
|
||||
# If we are cutting 'hotfix-rc':
|
||||
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
|
||||
|
||||
# If the 'rc' branch exists:
|
||||
if [[ $(git ls-remote --heads origin rc) ]]; then
|
||||
|
||||
# Chery-pick from 'rc' into 'hotfix-rc'
|
||||
cherry_pick rc hotfix-rc
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
# If the 'rc' branch does not exist:
|
||||
else
|
||||
|
||||
# Cherry-pick from 'main' into 'hotfix-rc'
|
||||
cherry_pick main hotfix-rc
|
||||
|
||||
fi
|
||||
|
||||
# If we are cutting 'rc':
|
||||
elif [[ "$CUT_BRANCH" == "rc" ]]; then
|
||||
|
||||
# Cherry-pick from 'main' into 'rc'
|
||||
cherry_pick main rc
|
||||
|
||||
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
|
||||
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cut branch
|
||||
env:
|
||||
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||
run: |
|
||||
git switch --quiet --create $BRANCH_NAME
|
||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||
|
||||
move_future_db_scripts:
|
||||
name: Move finalization database scripts
|
||||
needs: cherry_pick
|
||||
needs: cut_branch
|
||||
uses: ./.github/workflows/_move_finalization_db_scripts.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
5
.github/workflows/stale-bot.yml
vendored
5
.github/workflows/stale-bot.yml
vendored
@ -8,6 +8,11 @@ jobs:
|
||||
stale:
|
||||
name: Check for stale issues and PRs
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
|
7
.github/workflows/test-database.yml
vendored
7
.github/workflows/test-database.yml
vendored
@ -31,10 +31,17 @@ on:
|
||||
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
||||
- "src/**/Entities/**/*.cs" # Database entity definitions
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.6.1</Version>
|
||||
<Version>2025.7.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -26,3 +26,12 @@ IDENTITY_PROXY_PORT=33756
|
||||
# Optional RabbitMQ configuration
|
||||
RABBITMQ_DEFAULT_USER=bitwarden
|
||||
RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
|
||||
|
||||
# Environment variables that help customize dev container start
|
||||
# Without these the dev container will ask these questions in an interactive manner
|
||||
# when possible (excluding running in GitHub Codespaces)
|
||||
# SETUP_SECRETS_JSON=yes
|
||||
# SETUP_AZURITE=yes
|
||||
# RUN_MSSQL_MIGRATIONS=yes
|
||||
# DEV_CERT_PASSWORD=dev_cert_password_here
|
||||
# INSTALL_STRIPE_CLI=no
|
||||
|
@ -6,6 +6,7 @@ using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
@ -34,14 +35,13 @@ namespace Bit.Admin.AdminConsole.Controllers;
|
||||
public class ProvidersController : Controller
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
@ -54,14 +54,13 @@ public class ProvidersController : Controller
|
||||
|
||||
public ProvidersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderService providerService,
|
||||
GlobalSettings globalSettings,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IUserService userService,
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
@ -71,14 +70,13 @@ public class ProvidersController : Controller
|
||||
IStripeAdapter stripeAdapter)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_providerService = providerService;
|
||||
_globalSettings = globalSettings;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_userService = userService;
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
@ -459,7 +457,7 @@ public class ProvidersController : Controller
|
||||
}
|
||||
|
||||
var organization = model.CreateOrganization(provider);
|
||||
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
|
||||
await _resellerClientOrganizationSignUpCommand.SignUpResellerClientAsync(organization, model.Owners);
|
||||
await _providerService.AddOrganization(providerId, organization.Id, null);
|
||||
|
||||
return RedirectToAction("Edit", "Providers", new { id = providerId });
|
||||
|
@ -59,6 +59,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy app from the build stage
|
||||
|
@ -403,16 +403,15 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task Confirm(string orgId, string id, [FromBody] OrganizationUserConfirmRequestModel model)
|
||||
public async Task Confirm(Guid orgId, Guid id, [FromBody] OrganizationUserConfirmRequestModel model)
|
||||
{
|
||||
var orgGuidId = new Guid(orgId);
|
||||
if (!await _currentContext.ManageUsers(orgGuidId))
|
||||
if (!await _currentContext.ManageUsers(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
|
||||
var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
@ -521,7 +520,9 @@ public class OrganizationUsersController : Controller
|
||||
.Concat(readonlyCollectionAccess)
|
||||
.ToList();
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId,
|
||||
var existingUserType = organizationUser.Type;
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
|
||||
collectionsToSave, groupsToSave);
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
|
@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
@ -15,6 +15,8 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
[Required]
|
||||
public EventType EventType { get; set; }
|
||||
|
||||
public string? Filters { get; set; }
|
||||
|
||||
public string? Template { get; set; }
|
||||
|
||||
public bool IsValidForType(IntegrationType integrationType)
|
||||
@ -24,9 +26,13 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
|
||||
return false;
|
||||
case IntegrationType.Slack:
|
||||
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<SlackIntegrationConfiguration>();
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
IsConfigurationValid<SlackIntegrationConfiguration>() &&
|
||||
IsFiltersValid();
|
||||
case IntegrationType.Webhook:
|
||||
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<WebhookIntegrationConfiguration>();
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
IsConfigurationValid<WebhookIntegrationConfiguration>() &&
|
||||
IsFiltersValid();
|
||||
default:
|
||||
return false;
|
||||
|
||||
@ -39,6 +45,7 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
OrganizationIntegrationId = organizationIntegrationId,
|
||||
Configuration = Configuration,
|
||||
Filters = Filters,
|
||||
EventType = EventType,
|
||||
Template = Template
|
||||
};
|
||||
@ -48,6 +55,7 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
currentConfiguration.Configuration = Configuration;
|
||||
currentConfiguration.EventType = EventType;
|
||||
currentConfiguration.Filters = Filters;
|
||||
currentConfiguration.Template = Template;
|
||||
|
||||
return currentConfiguration;
|
||||
@ -70,4 +78,22 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -60,6 +60,10 @@ public class OrganizationUserConfirmRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationUserBulkConfirmRequestModelEntry
|
||||
|
@ -17,11 +17,13 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
Configuration = organizationIntegrationConfiguration.Configuration;
|
||||
CreationDate = organizationIntegrationConfiguration.CreationDate;
|
||||
EventType = organizationIntegrationConfiguration.EventType;
|
||||
Filters = organizationIntegrationConfiguration.Filters;
|
||||
Template = organizationIntegrationConfiguration.Template;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public string? Configuration { get; set; }
|
||||
public string? Filters { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public EventType EventType { get; set; }
|
||||
public string? Template { get; set; }
|
||||
|
@ -177,9 +177,10 @@ public class MembersController : Controller
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var existingUserType = existingUser.Type;
|
||||
var updatedUser = model.ToOrganizationUser(existingUser);
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups);
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
|
||||
MemberResponseModel response = null;
|
||||
if (existingUser.UserId.HasValue)
|
||||
{
|
||||
|
@ -1,34 +1,21 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Api.KeyManagement.Validators;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -45,22 +32,9 @@ public class AccountsController : Controller
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
|
||||
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
|
||||
private readonly IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> _sendValidator;
|
||||
private readonly IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
|
||||
_emergencyAccessValidator;
|
||||
private readonly IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>,
|
||||
IReadOnlyList<OrganizationUser>>
|
||||
_organizationUserValidator;
|
||||
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
_webauthnKeyValidator;
|
||||
|
||||
|
||||
public AccountsController(
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -69,17 +43,8 @@ public class AccountsController : Controller
|
||||
IPolicyService policyService,
|
||||
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
|
||||
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
|
||||
IRotateUserKeyCommand rotateUserKeyCommand,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IFeatureService featureService,
|
||||
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
|
||||
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
|
||||
IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> sendValidator,
|
||||
IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator
|
||||
IFeatureService featureService
|
||||
)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
@ -89,15 +54,8 @@ public class AccountsController : Controller
|
||||
_policyService = policyService;
|
||||
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
|
||||
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
|
||||
_rotateUserKeyCommand = rotateUserKeyCommand;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_featureService = featureService;
|
||||
_cipherValidator = cipherValidator;
|
||||
_folderValidator = folderValidator;
|
||||
_sendValidator = sendValidator;
|
||||
_emergencyAccessValidator = emergencyAccessValidator;
|
||||
_organizationUserValidator = organizationUserValidator;
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
}
|
||||
|
||||
|
||||
@ -313,45 +271,6 @@ public class AccountsController : Controller
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")]
|
||||
[HttpPost("key")]
|
||||
public async Task PostKey([FromBody] UpdateKeyRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var dataModel = new RotateUserKeyData
|
||||
{
|
||||
MasterPasswordHash = model.MasterPasswordHash,
|
||||
Key = model.Key,
|
||||
PrivateKey = model.PrivateKey,
|
||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers),
|
||||
Folders = await _folderValidator.ValidateAsync(user, model.Folders),
|
||||
Sends = await _sendValidator.ValidateAsync(user, model.Sends),
|
||||
EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys),
|
||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys),
|
||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.WebAuthnKeys)
|
||||
};
|
||||
|
||||
var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("security-stamp")]
|
||||
public async Task PostSecurityStamp([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Services;
|
||||
@ -7,6 +8,7 @@ using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -14,31 +16,23 @@ namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("auth-requests")]
|
||||
[Authorize("Application")]
|
||||
public class AuthRequestsController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService;
|
||||
|
||||
public AuthRequestsController(
|
||||
public class AuthRequestsController(
|
||||
IUserService userService,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IAuthRequestService authRequestService)
|
||||
{
|
||||
_userService = userService;
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_authRequestService = authRequestService;
|
||||
}
|
||||
IAuthRequestService authRequestService) : Controller
|
||||
{
|
||||
private readonly IUserService _userService = userService;
|
||||
private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository;
|
||||
private readonly IGlobalSettings _globalSettings = globalSettings;
|
||||
private readonly IAuthRequestService _authRequestService = authRequestService;
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<AuthRequestResponseModel>> Get()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -56,6 +50,16 @@ public class AuthRequestsController : Controller
|
||||
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")]
|
||||
[AllowAnonymous]
|
||||
public async Task<AuthRequestResponseModel> GetResponse(Guid id, [FromQuery] string code)
|
||||
|
@ -4,7 +4,6 @@ using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -72,7 +71,7 @@ public class EmergencyAccessController : Controller
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(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);
|
||||
}
|
||||
|
||||
|
@ -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; }
|
||||
}
|
@ -20,6 +20,8 @@ public class CollectionsController : Controller
|
||||
{
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly ICreateCollectionCommand _createCollectionCommand;
|
||||
private readonly IUpdateCollectionCommand _updateCollectionCommand;
|
||||
private readonly IDeleteCollectionCommand _deleteCollectionCommand;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
@ -29,6 +31,8 @@ public class CollectionsController : Controller
|
||||
public CollectionsController(
|
||||
ICollectionRepository collectionRepository,
|
||||
ICollectionService collectionService,
|
||||
ICreateCollectionCommand createCollectionCommand,
|
||||
IUpdateCollectionCommand updateCollectionCommand,
|
||||
IDeleteCollectionCommand deleteCollectionCommand,
|
||||
IUserService userService,
|
||||
IAuthorizationService authorizationService,
|
||||
@ -37,6 +41,8 @@ public class CollectionsController : Controller
|
||||
{
|
||||
_collectionRepository = collectionRepository;
|
||||
_collectionService = collectionService;
|
||||
_createCollectionCommand = createCollectionCommand;
|
||||
_updateCollectionCommand = updateCollectionCommand;
|
||||
_deleteCollectionCommand = deleteCollectionCommand;
|
||||
_userService = userService;
|
||||
_authorizationService = authorizationService;
|
||||
@ -153,7 +159,7 @@ public class CollectionsController : Controller
|
||||
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
|
||||
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)))
|
||||
{
|
||||
@ -179,7 +185,7 @@ public class CollectionsController : Controller
|
||||
|
||||
var groups = model.Groups?.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)))
|
||||
{
|
||||
@ -192,19 +198,6 @@ public class CollectionsController : Controller
|
||||
return new CollectionAccessDetailsResponseModel(collectionWithPermissions);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/users")]
|
||||
public async Task PutUsers(Guid orgId, Guid id, [FromBody] IEnumerable<SelectionReadOnlyRequestModel> model)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
|
||||
}
|
||||
|
||||
[HttpPost("bulk-access")]
|
||||
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
|
||||
{
|
||||
@ -255,18 +248,4 @@ public class CollectionsController : Controller
|
||||
|
||||
await _deleteCollectionCommand.DeleteManyAsync(collections);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/user/{orgUserId}")]
|
||||
[HttpPost("{id}/delete-user/{orgUserId}")]
|
||||
public async Task DeleteUser(Guid orgId, Guid id, Guid orgUserId)
|
||||
{
|
||||
var collection = await _collectionRepository.GetByIdAsync(id);
|
||||
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _collectionService.DeleteUserAsync(collection, orgUserId);
|
||||
}
|
||||
}
|
||||
|
@ -206,7 +206,11 @@ public class DevicesController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _deviceService.SaveAsync(model.ToData(), device);
|
||||
await _deviceService.SaveAsync(
|
||||
model.ToData(),
|
||||
device,
|
||||
_currentContext.Organizations.Select(org => org.Id.ToString())
|
||||
);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
|
@ -1,7 +1,8 @@
|
||||
using Bit.Api.Dirt.Models;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
@ -17,24 +18,36 @@ namespace Bit.Api.Dirt.Controllers;
|
||||
public class ReportsController : Controller
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||
private readonly IMemberAccessReportQuery _memberAccessReportQuery;
|
||||
private readonly IRiskInsightsReportQuery _riskInsightsReportQuery;
|
||||
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
|
||||
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
|
||||
private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand;
|
||||
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
|
||||
|
||||
public ReportsController(
|
||||
ICurrentContext currentContext,
|
||||
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||
IMemberAccessReportQuery memberAccessReportQuery,
|
||||
IRiskInsightsReportQuery riskInsightsReportQuery,
|
||||
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
|
||||
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand
|
||||
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
|
||||
IGetOrganizationReportQuery getOrganizationReportQuery,
|
||||
IAddOrganizationReportCommand addOrganizationReportCommand,
|
||||
IDropOrganizationReportCommand dropOrganizationReportCommand
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||
_memberAccessReportQuery = memberAccessReportQuery;
|
||||
_riskInsightsReportQuery = riskInsightsReportQuery;
|
||||
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
|
||||
_getOrganizationReportQuery = getOrganizationReportQuery;
|
||||
_addOrganizationReportCommand = addOrganizationReportCommand;
|
||||
_dropOrganizationReportCommand = dropOrganizationReportCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -54,9 +67,9 @@ public class ReportsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
var riskDetails = await GetRiskInsightsReportDetails(new RiskInsightsReportRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||
var responses = riskDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -69,16 +82,16 @@ public class ReportsController : Controller
|
||||
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||
[HttpGet("member-access/{orgId}")]
|
||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
public async Task<IEnumerable<MemberAccessDetailReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||
var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId });
|
||||
|
||||
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||
var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x));
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -87,13 +100,28 @@ public class ReportsController : Controller
|
||||
/// Contains the organization member info, the cipher ids associated with the member,
|
||||
/// and details on their collections, groups, and permissions
|
||||
/// </summary>
|
||||
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||
/// <param name="request">Request parameters</param>
|
||||
/// <returns>
|
||||
/// List of a user's permissions at a group and collection level as well as the number of ciphers
|
||||
/// associated with that group/collection
|
||||
/// </returns>
|
||||
private async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessDetails(
|
||||
MemberAccessReportRequest request)
|
||||
{
|
||||
var memberCipherDetails =
|
||||
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||
return memberCipherDetails;
|
||||
var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
|
||||
return accessDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids
|
||||
/// </summary>
|
||||
/// <param name="request">Request parameters</param>
|
||||
/// <returns>A list of risk insights data associating the user to cipher ids</returns>
|
||||
private async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(
|
||||
RiskInsightsReportRequest request)
|
||||
{
|
||||
var riskDetails = await _riskInsightsReportQuery.GetRiskInsightsReportDetails(request);
|
||||
return riskDetails;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -185,4 +213,72 @@ public class ReportsController : Controller
|
||||
|
||||
await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new organization report
|
||||
/// </summary>
|
||||
/// <param name="request">A single instance of AddOrganizationReportRequest</param>
|
||||
/// <returns>A single instance of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpPost("organization-reports")]
|
||||
public async Task<OrganizationReport> AddOrganizationReport([FromBody] AddOrganizationReportRequest request)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops organization reports for an organization
|
||||
/// </summary>
|
||||
/// <param name="request">A single instance of DropOrganizationReportRequest</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization does not have any records</exception>
|
||||
[HttpDelete("organization-reports")]
|
||||
public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _dropOrganizationReportCommand.DropOrganizationReportAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets organization reports for an organization
|
||||
/// </summary>
|
||||
/// <param name="orgId">A valid Organization Id</param>
|
||||
/// <returns>An Enumerable of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpGet("organization-reports/{orgId}")]
|
||||
public async Task<IEnumerable<OrganizationReport>> GetOrganizationReports(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest organization report for an organization
|
||||
/// </summary>
|
||||
/// <param name="orgId">A valid Organization Id</param>
|
||||
/// <returns>A single instance of OrganizationReport</returns>
|
||||
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
|
||||
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||
[HttpGet("organization-reports/latest/{orgId}")]
|
||||
public async Task<OrganizationReport> GetLatestOrganizationReport(Guid orgId)
|
||||
{
|
||||
if (!await _currentContext.AccessReports(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
public class MemberAccessDetailReportResponseModel
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public bool AccountRecoveryEnabled { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
|
||||
public MemberAccessDetailReportResponseModel(MemberAccessReportDetail reportDetail)
|
||||
{
|
||||
UserGuid = reportDetail.UserGuid;
|
||||
UserName = reportDetail.UserName;
|
||||
Email = reportDetail.Email;
|
||||
TwoFactorEnabled = reportDetail.TwoFactorEnabled;
|
||||
AccountRecoveryEnabled = reportDetail.AccountRecoveryEnabled;
|
||||
UsesKeyConnector = reportDetail.UsesKeyConnector;
|
||||
CollectionId = reportDetail.CollectionId;
|
||||
GroupId = reportDetail.GroupId;
|
||||
GroupName = reportDetail.GroupName;
|
||||
CollectionName = reportDetail.CollectionName;
|
||||
ReadOnly = reportDetail.ReadOnly;
|
||||
HidePasswords = reportDetail.HidePasswords;
|
||||
Manage = reportDetail.Manage;
|
||||
CipherIds = reportDetail.CipherIds;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Models.Data;
|
||||
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class MemberCipherDetailsResponseModel
|
||||
@ -15,12 +14,12 @@ public class MemberCipherDetailsResponseModel
|
||||
/// </summary>
|
||||
public IEnumerable<string> CipherIds { get; set; }
|
||||
|
||||
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||
public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail)
|
||||
{
|
||||
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||
this.UserName = memberAccessCipherDetails.UserName;
|
||||
this.Email = memberAccessCipherDetails.Email;
|
||||
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||
this.UserGuid = reportDetail.UserGuid;
|
||||
this.UserName = reportDetail.UserName;
|
||||
this.Email = reportDetail.Email;
|
||||
this.UsesKeyConnector = reportDetail.UsesKeyConnector;
|
||||
this.CipherIds = reportDetail.CipherIds;
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
using Bit.Core.Context;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.NotificationHub;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -20,14 +23,14 @@ namespace Bit.Api.Platform.Push;
|
||||
public class PushController : Controller
|
||||
{
|
||||
private readonly IPushRegistrationService _pushRegistrationService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly IPushRelayer _pushRelayer;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
|
||||
public PushController(
|
||||
IPushRegistrationService pushRegistrationService,
|
||||
IPushNotificationService pushNotificationService,
|
||||
IPushRelayer pushRelayer,
|
||||
IWebHostEnvironment environment,
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings)
|
||||
@ -35,7 +38,7 @@ public class PushController : Controller
|
||||
_currentContext = currentContext;
|
||||
_environment = environment;
|
||||
_pushRegistrationService = pushRegistrationService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
_pushRelayer = pushRelayer;
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
@ -74,31 +77,50 @@ public class PushController : Controller
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
public async Task SendAsync([FromBody] PushSendRequestModel model)
|
||||
public async Task SendAsync([FromBody] PushSendRequestModel<JsonElement> model)
|
||||
{
|
||||
CheckUsage();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.InstallationId))
|
||||
NotificationTarget target;
|
||||
Guid targetId;
|
||||
|
||||
if (model.InstallationId.HasValue)
|
||||
{
|
||||
if (_currentContext.InstallationId!.Value.ToString() != model.InstallationId!)
|
||||
if (_currentContext.InstallationId!.Value != model.InstallationId.Value)
|
||||
{
|
||||
throw new BadRequestException("InstallationId does not match current context.");
|
||||
}
|
||||
|
||||
await _pushNotificationService.SendPayloadToInstallationAsync(
|
||||
_currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier),
|
||||
Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.Installation;
|
||||
targetId = _currentContext.InstallationId.Value;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserId))
|
||||
else if (model.UserId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId),
|
||||
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.User;
|
||||
targetId = model.UserId.Value;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.OrganizationId))
|
||||
else if (model.OrganizationId.HasValue)
|
||||
{
|
||||
await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId),
|
||||
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
|
||||
target = NotificationTarget.Organization;
|
||||
targetId = model.OrganizationId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new UnreachableException("Model validation should have prevented getting here.");
|
||||
}
|
||||
|
||||
var notification = new RelayedNotification
|
||||
{
|
||||
Type = model.Type,
|
||||
Target = target,
|
||||
TargetId = targetId,
|
||||
Payload = model.Payload,
|
||||
Identifier = model.Identifier,
|
||||
DeviceId = model.DeviceId,
|
||||
ClientType = model.ClientType,
|
||||
};
|
||||
|
||||
await _pushRelayer.RelayAsync(_currentContext.InstallationId.Value, notification);
|
||||
}
|
||||
|
||||
private string Prefix(string value)
|
||||
|
@ -2,6 +2,8 @@
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -14,18 +16,18 @@ namespace Bit.Api.Public.Controllers;
|
||||
public class CollectionsController : Controller
|
||||
{
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICollectionService _collectionService;
|
||||
private readonly IUpdateCollectionCommand _updateCollectionCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
|
||||
public CollectionsController(
|
||||
ICollectionRepository collectionRepository,
|
||||
ICollectionService collectionService,
|
||||
IUpdateCollectionCommand updateCollectionCommand,
|
||||
ICurrentContext currentContext,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
{
|
||||
_collectionRepository = collectionRepository;
|
||||
_collectionService = collectionService;
|
||||
_updateCollectionCommand = updateCollectionCommand;
|
||||
_currentContext = currentContext;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
}
|
||||
@ -93,7 +95,7 @@ public class CollectionsController : Controller
|
||||
}
|
||||
var updatedCollection = model.ToCollection(existingCollection);
|
||||
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);
|
||||
return new JsonResult(response);
|
||||
}
|
||||
@ -115,6 +117,12 @@ public class CollectionsController : Controller
|
||||
{
|
||||
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);
|
||||
return new OkResult();
|
||||
}
|
||||
|
@ -15,5 +15,6 @@ public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
||||
public string? Template { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Filters { get; set; }
|
||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ public enum PolicyType : byte
|
||||
PasswordGenerator = 2,
|
||||
SingleOrg = 3,
|
||||
RequireSso = 4,
|
||||
PersonalOwnership = 5,
|
||||
OrganizationDataOwnership = 5,
|
||||
DisableSend = 6,
|
||||
SendOptions = 7,
|
||||
ResetPassword = 8,
|
||||
@ -35,7 +35,7 @@ public static class PolicyTypeExtensions
|
||||
PolicyType.PasswordGenerator => "Password generator",
|
||||
PolicyType.SingleOrg => "Single organization",
|
||||
PolicyType.RequireSso => "Require single sign-on authentication",
|
||||
PolicyType.PersonalOwnership => "Remove individual vault",
|
||||
PolicyType.OrganizationDataOwnership => "Enforce organization data ownership",
|
||||
PolicyType.DisableSend => "Remove Send",
|
||||
PolicyType.SendOptions => "Send options",
|
||||
PolicyType.ResetPassword => "Account recovery administration",
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public interface IIntegrationMessage
|
||||
{
|
@ -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; }
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
#nullable enable
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public enum IntegrationFilterOperation
|
||||
{
|
||||
Equals = 0,
|
||||
NotEquals = 1,
|
||||
In = 2,
|
||||
NotIn = 3
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationHandlerResult
|
||||
{
|
@ -3,7 +3,7 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationMessage : IIntegrationMessage
|
||||
{
|
@ -5,7 +5,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationTemplateContext(EventMessage eventMessage)
|
||||
{
|
@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string Token);
|
@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string ChannelId);
|
@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string ChannelId, string Token);
|
@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(string Url, string? Scheme = null, string? Token = null);
|
@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetails(string Url, string? Scheme = null, string? Token = null);
|
@ -1,5 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegration(string token);
|
@ -1,5 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string channelId);
|
@ -1,5 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string channelId, string token);
|
@ -1,5 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(string url);
|
@ -1,5 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetails(string url);
|
@ -12,6 +12,7 @@ public class OrganizationIntegrationConfigurationDetails
|
||||
public IntegrationType IntegrationType { get; set; }
|
||||
public EventType EventType { get; set; }
|
||||
public string? Configuration { get; set; }
|
||||
public string? Filters { get; set; }
|
||||
public string? IntegrationConfiguration { get; set; }
|
||||
public string? Template { get; set; }
|
||||
|
||||
|
@ -163,6 +163,11 @@ public class UpdateGroupCommand : IUpdateGroupCommand
|
||||
// Use generic error message to avoid enumeration
|
||||
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,
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -28,6 +29,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
|
||||
public ConfirmOrganizationUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -41,7 +43,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
IPolicyService policyService,
|
||||
IDeviceRepository deviceRepository,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -55,10 +58,11 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
_deviceRepository = deviceRepository;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_featureService = featureService;
|
||||
_collectionRepository = collectionRepository;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
Guid confirmingUserId)
|
||||
Guid confirmingUserId, string defaultUserCollectionName = null)
|
||||
{
|
||||
var result = await ConfirmUsersAsync(
|
||||
organizationId,
|
||||
@ -75,6 +79,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
{
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
|
||||
await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
|
||||
|
||||
return orgUser;
|
||||
}
|
||||
|
||||
@ -213,4 +220,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
|
||||
.Select(d => d.Id.ToString());
|
||||
}
|
||||
|
||||
private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, OrganizationUser organizationUser, string defaultUserCollectionName)
|
||||
{
|
||||
// Create DefaultUserCollection type collection for the user if the OrganizationDataOwnership policy is enabled for the organization
|
||||
var requiresDefaultCollection = await OrganizationRequiresDefaultCollectionAsync(organizationId, organizationUser.UserId.Value, defaultUserCollectionName);
|
||||
if (requiresDefaultCollection)
|
||||
{
|
||||
await CreateDefaultCollectionAsync(organizationId, organizationUser.Id, defaultUserCollectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> OrganizationRequiresDefaultCollectionAsync(Guid organizationId, Guid userId, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var organizationDataOwnershipRequirement = await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId);
|
||||
return organizationDataOwnershipRequirement.RequiresDefaultCollection(organizationId);
|
||||
}
|
||||
|
||||
private async Task CreateDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
|
||||
{
|
||||
var collection = new Collection
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Name = defaultCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
};
|
||||
|
||||
var userAccess = new List<CollectionAccessSelection>
|
||||
{
|
||||
new CollectionAccessSelection
|
||||
{
|
||||
Id = organizationUserId,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true
|
||||
}
|
||||
};
|
||||
|
||||
await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess);
|
||||
}
|
||||
}
|
||||
|
@ -15,9 +15,10 @@ public interface IConfirmOrganizationUserCommand
|
||||
/// <param name="organizationUserId">The ID of the organization user to confirm.</param>
|
||||
/// <param name="key">The encrypted organization key for the user.</param>
|
||||
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
|
||||
/// <param name="defaultUserCollectionName">Optional encrypted collection name for creating a default collection.</param>
|
||||
/// <returns>The confirmed organization user.</returns>
|
||||
/// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception>
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
|
||||
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Confirms multiple organization users who have accepted their invitations.
|
||||
|
@ -1,11 +1,12 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IUpdateOrganizationUserCommand
|
||||
{
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
|
||||
}
|
||||
|
@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
/// Update an organization user.
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">The modified organization user to save.</param>
|
||||
/// <param name="existingUserType">The current type (member role) of the user.</param>
|
||||
/// <param name="savingUserId">The userId of the currently logged in user who is making the change.</param>
|
||||
/// <param name="collectionAccess">The user's updated collection access. If set to null, this removes all collection access.</param>
|
||||
/// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
|
||||
/// <exception cref="BadRequestException"></exception>
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType,
|
||||
Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
|
||||
{
|
||||
// Avoid multiple enumeration
|
||||
@ -83,15 +85,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
|
||||
{
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId.Value);
|
||||
if (adminCount > 0)
|
||||
{
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
}
|
||||
await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization);
|
||||
|
||||
if (collectionAccessList.Count != 0)
|
||||
{
|
||||
@ -151,6 +145,40 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);
|
||||
}
|
||||
|
||||
private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization)
|
||||
{
|
||||
|
||||
if (organization.PlanType != PlanType.Free)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!updatedOrgUser.UserId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value);
|
||||
|
||||
var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner;
|
||||
|
||||
if (isCurrentAdminOrOwner && adminCount <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCurrentAdminOrOwner && adminCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
|
||||
private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
|
||||
ICollection<CollectionAccessSelection> collectionAccess)
|
||||
{
|
||||
@ -171,6 +199,11 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
// Use generic error message to avoid enumeration
|
||||
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,
|
||||
|
@ -0,0 +1,130 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
|
||||
public record ResellerClientOrganizationSignUpResponse(
|
||||
Organization Organization,
|
||||
OrganizationUser OwnerOrganizationUser);
|
||||
|
||||
/// <summary>
|
||||
/// Command for signing up reseller client organizations in a pending state.
|
||||
/// </summary>
|
||||
public interface IResellerClientOrganizationSignUpCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign up a reseller client organization. The organization will be created in a pending state
|
||||
/// (disabled and with Pending status) and the owner will be invited via email. The organization
|
||||
/// will become active once the owner accepts the invitation.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to create.</param>
|
||||
/// <param name="ownerEmail">The email of the organization owner who will be invited.</param>
|
||||
/// <returns>A response containing the created pending organization and invited owner user.</returns>
|
||||
Task<ResellerClientOrganizationSignUpResponse> SignUpResellerClientAsync(
|
||||
Organization organization,
|
||||
string ownerEmail);
|
||||
}
|
||||
|
||||
public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizationSignUpCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||
private readonly IPaymentService _paymentService;
|
||||
|
||||
public ResellerClientOrganizationSignUpCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationApiKeyRepository organizationApiKeyRepository,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IEventService eventService,
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||
IPaymentService paymentService)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationApiKeyRepository = organizationApiKeyRepository;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_eventService = eventService;
|
||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||
_paymentService = paymentService;
|
||||
}
|
||||
|
||||
public async Task<ResellerClientOrganizationSignUpResponse> SignUpResellerClientAsync(
|
||||
Organization organization,
|
||||
string ownerEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
var createdOrganization = await CreateOrganizationAsync(organization);
|
||||
var ownerOrganizationUser = await CreateAndInviteOwnerAsync(createdOrganization, ownerEmail);
|
||||
|
||||
await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
|
||||
|
||||
return new ResellerClientOrganizationSignUpResponse(organization, ownerOrganizationUser);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _paymentService.CancelAndRecoverChargesAsync(organization);
|
||||
|
||||
if (organization.Id != default)
|
||||
{
|
||||
// Deletes the organization and all related data, including its owner user
|
||||
await _organizationRepository.DeleteAsync(organization);
|
||||
await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Organization> CreateOrganizationAsync(Organization organization)
|
||||
{
|
||||
organization.Id = CoreHelpers.GenerateComb();
|
||||
organization.Enabled = false;
|
||||
organization.Status = OrganizationStatusType.Pending;
|
||||
|
||||
await _organizationRepository.CreateAsync(organization);
|
||||
await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30),
|
||||
Type = OrganizationApiKeyType.Default,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
});
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUser> CreateAndInviteOwnerAsync(Organization organization, string ownerEmail)
|
||||
{
|
||||
var ownerOrganizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = null,
|
||||
Email = ownerEmail,
|
||||
Key = null,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
};
|
||||
|
||||
await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
|
||||
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
|
||||
users: [ownerOrganizationUser],
|
||||
organization: organization,
|
||||
initOrganization: true));
|
||||
|
||||
return ownerOrganizationUser;
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the Organization Data Ownership policy state.
|
||||
/// </summary>
|
||||
public enum OrganizationDataOwnershipState
|
||||
{
|
||||
/// <summary>
|
||||
/// Organization Data Ownership is enforced- members are required to save items to an organization.
|
||||
/// </summary>
|
||||
Enabled = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Organization Data Ownership is not enforced- users can save items to their personal vault.
|
||||
/// </summary>
|
||||
Disabled = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy requirements for the Organization data ownership policy
|
||||
/// </summary>
|
||||
public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement
|
||||
{
|
||||
private readonly IEnumerable<Guid> _organizationIdsWithPolicyEnabled;
|
||||
|
||||
/// <param name="organizationDataOwnershipState">
|
||||
/// The organization data ownership state for the user.
|
||||
/// </param>
|
||||
/// <param name="organizationIdsWithPolicyEnabled">
|
||||
/// The collection of Organization IDs that have the Organization Data Ownership policy enabled.
|
||||
/// </param>
|
||||
public OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState organizationDataOwnershipState,
|
||||
IEnumerable<Guid> organizationIdsWithPolicyEnabled)
|
||||
{
|
||||
_organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? [];
|
||||
State = organizationDataOwnershipState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Organization data ownership policy state for the user.
|
||||
/// </summary>
|
||||
public OrganizationDataOwnershipState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the Organization Data Ownership policy is enforced in that organization.
|
||||
/// </summary>
|
||||
public bool RequiresDefaultCollection(Guid organizationId)
|
||||
{
|
||||
return _organizationIdsWithPolicyEnabled.Contains(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<OrganizationDataOwnershipPolicyRequirement>
|
||||
{
|
||||
public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership;
|
||||
|
||||
public override OrganizationDataOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||
{
|
||||
var organizationDataOwnershipState = policyDetails.Any()
|
||||
? OrganizationDataOwnershipState.Enabled
|
||||
: OrganizationDataOwnershipState.Disabled;
|
||||
var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet();
|
||||
|
||||
return new OrganizationDataOwnershipPolicyRequirement(
|
||||
organizationDataOwnershipState,
|
||||
organizationIdsWithPolicyEnabled);
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
|
||||
/// <summary>
|
||||
/// Policy requirements for the Disable Personal Ownership policy.
|
||||
/// </summary>
|
||||
public class PersonalOwnershipPolicyRequirement : IPolicyRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization.
|
||||
/// </summary>
|
||||
public bool DisablePersonalOwnership { get; init; }
|
||||
}
|
||||
|
||||
public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<PersonalOwnershipPolicyRequirement>
|
||||
{
|
||||
public override PolicyType PolicyType => PolicyType.PersonalOwnership;
|
||||
|
||||
public override PersonalOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
|
||||
{
|
||||
var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() };
|
||||
return result;
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, DisableSendPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, OrganizationDataOwnershipPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>();
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
11
src/Core/AdminConsole/Services/IIntegrationFilterService.cs
Normal file
11
src/Core/AdminConsole/Services/IIntegrationFilterService.cs
Normal 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);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
@ -42,7 +41,6 @@ public interface IOrganizationService
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -33,6 +33,13 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await _processor.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
_logger.LogError(
|
||||
@ -49,16 +56,4 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_processor.DisposeAsync().GetAwaiter().GetResult();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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[]");
|
||||
}
|
||||
}
|
@ -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.
|
@ -2,7 +2,7 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
@ -20,6 +20,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
|
||||
string routingKey,
|
||||
@ -27,7 +28,8 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
string retryQueueName,
|
||||
int maxRetries,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILogger<RabbitMqIntegrationListenerService> logger)
|
||||
ILogger<RabbitMqIntegrationListenerService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_handler = handler;
|
||||
_routingKey = routingKey;
|
||||
@ -35,6 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
_queueName = queueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_maxRetries = maxRetries;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
}
|
||||
@ -74,7 +77,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
|
||||
if (integrationMessage is not null &&
|
||||
integrationMessage.DelayUntilDate.HasValue &&
|
||||
integrationMessage.DelayUntilDate.Value > DateTime.UtcNow)
|
||||
integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
|
||||
{
|
||||
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
@ -1,7 +1,7 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
@ -1,6 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
@ -11,9 +11,9 @@ public class SlackIntegrationHandler(
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
|
||||
{
|
||||
await slackService.SendSlackMessageByChannelIdAsync(
|
||||
message.Configuration.token,
|
||||
message.Configuration.Token,
|
||||
message.RenderedTemplate,
|
||||
message.Configuration.channelId
|
||||
message.Configuration.ChannelId
|
||||
);
|
||||
|
||||
return new IntegrationHandlerResult(success: true, message: message);
|
@ -2,14 +2,17 @@
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
public class WebhookIntegrationHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TimeProvider timeProvider)
|
||||
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
@ -18,8 +21,16 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PostAsync(message.Configuration.url, content);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Url);
|
||||
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);
|
||||
|
||||
switch (response.StatusCode)
|
||||
@ -39,7 +50,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
if (int.TryParse(value, out var seconds))
|
||||
{
|
||||
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
|
||||
result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds);
|
||||
result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
|
||||
}
|
||||
else if (DateTimeOffset.TryParseExact(value,
|
||||
"r", // "r" is the round-trip format: RFC1123
|
@ -1,5 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -12,6 +11,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
@ -199,6 +199,7 @@ public class OrganizationService : IOrganizationService
|
||||
{
|
||||
await AdjustSeatsAsync(organization, seatAdjustment);
|
||||
}
|
||||
|
||||
if (maxAutoscaleSeats != organization.MaxAutoscaleSeats)
|
||||
{
|
||||
await UpdateAutoscalingAsync(organization, maxAutoscaleSeats);
|
||||
@ -207,7 +208,6 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats)
|
||||
{
|
||||
|
||||
if (maxAutoscaleSeats.HasValue &&
|
||||
organization.Seats.HasValue &&
|
||||
maxAutoscaleSeats.Value < organization.Seats.Value)
|
||||
@ -229,7 +229,8 @@ public class OrganizationService : IOrganizationService
|
||||
if (plan.PasswordManager.MaxSeats.HasValue && maxAutoscaleSeats.HasValue &&
|
||||
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}.",
|
||||
"Reduce your max autoscale seat count."));
|
||||
}
|
||||
@ -250,7 +251,8 @@ public class OrganizationService : IOrganizationService
|
||||
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)
|
||||
{
|
||||
@ -286,7 +288,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
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 " +
|
||||
$"{plan.PasswordManager.MaxAdditionalSeats.Value} additional seats.");
|
||||
@ -300,7 +303,8 @@ public class OrganizationService : IOrganizationService
|
||||
{
|
||||
if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0)
|
||||
{
|
||||
throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
|
||||
throw new BadRequestException(
|
||||
$"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
|
||||
@ -320,7 +324,8 @@ public class OrganizationService : IOrganizationService
|
||||
organization.Seats = (short?)newSeatTotal;
|
||||
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
|
||||
{
|
||||
@ -329,7 +334,9 @@ public class OrganizationService : IOrganizationService
|
||||
ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
|
||||
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)
|
||||
{
|
||||
@ -423,50 +430,11 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
await ValidateSignUpPoliciesAsync(owner.Id);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Name = license.Name,
|
||||
BillingEmail = license.BillingEmail,
|
||||
BusinessName = license.BusinessName,
|
||||
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 organization = claimsPrincipal != null
|
||||
// If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization.
|
||||
? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey)
|
||||
// If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization.
|
||||
: OrganizationFactory.Create(owner, license, publicKey, privateKey);
|
||||
|
||||
var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false);
|
||||
|
||||
@ -481,7 +449,8 @@ public class OrganizationService : IOrganizationService
|
||||
/// Private helper method to create a new organization.
|
||||
/// This is common code used by both the cloud and self-hosted methods.
|
||||
/// </summary>
|
||||
private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(Organization organization,
|
||||
private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)>
|
||||
SignUpAsync(Organization organization,
|
||||
Guid ownerId, string ownerKey, string collectionName, bool withPayment)
|
||||
{
|
||||
try
|
||||
@ -538,7 +507,15 @@ public class OrganizationService : IOrganizationService
|
||||
if (orgUser != null)
|
||||
{
|
||||
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);
|
||||
@ -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))
|
||||
{
|
||||
@ -595,7 +573,8 @@ public class OrganizationService : IOrganizationService
|
||||
if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
|
||||
{
|
||||
var customerService = new CustomerService();
|
||||
await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
await customerService.UpdateAsync(organization.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Email = organization.BillingEmail,
|
||||
Description = organization.DisplayBusinessName()
|
||||
@ -649,7 +628,8 @@ public class OrganizationService : IOrganizationService
|
||||
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)
|
||||
{
|
||||
// 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));
|
||||
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,
|
||||
@ -673,6 +654,7 @@ public class OrganizationService : IOrganizationService
|
||||
{
|
||||
throw new BadRequestException("This user has already been invited.");
|
||||
}
|
||||
|
||||
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="invites">Details about the users being invited</param>
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null, invite.Permissions);
|
||||
await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null,
|
||||
invite.Permissions);
|
||||
await ValidateOrganizationCustomPermissionsEnabledAsync(organizationId, invite.Type.Value);
|
||||
}
|
||||
}
|
||||
@ -706,7 +690,8 @@ public class OrganizationService : IOrganizationService
|
||||
if (systemUser.HasValue)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
@ -717,7 +702,9 @@ public class OrganizationService : IOrganizationService
|
||||
return organizationUsers;
|
||||
}
|
||||
|
||||
private async Task<(List<OrganizationUser> organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> SaveUsersSendInvitesAsync(Guid organizationId,
|
||||
private async
|
||||
Task<(List<OrganizationUser> organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)>
|
||||
SaveUsersSendInvitesAsync(Guid organizationId,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites)
|
||||
{
|
||||
var organization = await GetOrgById(organizationId);
|
||||
@ -728,7 +715,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
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
|
||||
var initialSmSeatCount = organization.SmSeats;
|
||||
@ -756,7 +744,8 @@ public class OrganizationService : IOrganizationService
|
||||
.SelectMany(i => i.invite.Emails)
|
||||
.Count(email => !existingEmails.Contains(email));
|
||||
|
||||
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount);
|
||||
var additionalSmSeatsRequired =
|
||||
await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount);
|
||||
if (additionalSmSeatsRequired > 0)
|
||||
{
|
||||
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);
|
||||
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.");
|
||||
}
|
||||
@ -888,7 +879,8 @@ public class OrganizationService : IOrganizationService
|
||||
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);
|
||||
}
|
||||
@ -904,10 +896,13 @@ public class OrganizationService : IOrganizationService
|
||||
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)
|
||||
{
|
||||
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
|
||||
_logger.LogUserInviteStateDiagnostics(orgUsers);
|
||||
|
||||
var org = await GetOrgById(organizationId);
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
@ -926,7 +921,8 @@ public class OrganizationService : IOrganizationService
|
||||
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);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId ||
|
||||
@ -935,6 +931,8 @@ public class OrganizationService : IOrganizationService
|
||||
throw new BadRequestException("User invalid.");
|
||||
}
|
||||
|
||||
_logger.LogUserInviteStateDiagnostics(orgUser);
|
||||
|
||||
var org = await GetOrgById(orgUser.OrganizationId);
|
||||
await SendInviteAsync(orgUser, org, initOrganization);
|
||||
}
|
||||
@ -1013,7 +1011,9 @@ public class OrganizationService : IOrganizationService
|
||||
IEnumerable<string> ownerEmails;
|
||||
if (providerOrg != null)
|
||||
{
|
||||
ownerEmails = (await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId, ProviderUserStatusType.Confirmed))
|
||||
ownerEmails =
|
||||
(await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId,
|
||||
ProviderUserStatusType.Confirmed))
|
||||
.Select(u => u.Email).Distinct();
|
||||
}
|
||||
else
|
||||
@ -1021,6 +1021,7 @@ public class OrganizationService : IOrganizationService
|
||||
ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
|
||||
OrganizationUserType.Owner)).Select(u => u.Email).Distinct();
|
||||
}
|
||||
|
||||
var initialSeatCount = organization.Seats.Value;
|
||||
|
||||
await AdjustSeatsAsync(organization, seatsToAdd, ownerEmails);
|
||||
@ -1035,8 +1036,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId)
|
||||
public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey,
|
||||
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
|
||||
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
|
||||
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))
|
||||
{
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
await _organizationUserRepository.ReplaceAsync(orgUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ?
|
||||
EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw);
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser,
|
||||
resetPasswordKey != null
|
||||
? EventType.OrganizationUser_ResetPassword_Enroll
|
||||
: EventType.OrganizationUser_ResetPassword_Withdraw);
|
||||
}
|
||||
|
||||
public async Task ImportAsync(Guid organizationId,
|
||||
@ -1124,7 +1130,8 @@ public class OrganizationService : IOrganizationService
|
||||
var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId);
|
||||
var removeUsersSet = new HashSet<string>(removeUserExternalIds)
|
||||
.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]);
|
||||
|
||||
await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id));
|
||||
@ -1176,6 +1183,7 @@ public class OrganizationService : IOrganizationService
|
||||
existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await _organizationUserRepository.UpsertManyAsync(usersToUpsert);
|
||||
|
||||
// Add new users
|
||||
@ -1186,7 +1194,8 @@ public class OrganizationService : IOrganizationService
|
||||
var enoughSeatsAvailable = true;
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var seatCounts =
|
||||
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
seatsAvailable = organization.Seats.Value - seatCounts.Total;
|
||||
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)
|
||||
{
|
||||
existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id);
|
||||
@ -1256,7 +1266,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
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
|
||||
.Where(g => groupsDict.ContainsKey(g.ExternalId))
|
||||
@ -1283,11 +1294,11 @@ public class OrganizationService : IOrganizationService
|
||||
await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds,
|
||||
existingExternalUsersIdDict,
|
||||
existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null);
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId);
|
||||
var organizationUser =
|
||||
await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId);
|
||||
if (organizationUser != null)
|
||||
{
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UnlinkedSso);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser,
|
||||
EventType.OrganizationUser_UnlinkedSso);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
{
|
||||
@ -1474,13 +1488,15 @@ public class OrganizationService : IOrganizationService
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId, OrganizationUserType newType)
|
||||
public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId,
|
||||
OrganizationUserType newType)
|
||||
{
|
||||
if (newType != OrganizationUserType.Custom)
|
||||
{
|
||||
@ -1495,7 +1511,8 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
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)
|
||||
{
|
||||
await RepositoryRevokeUserAsync(organizationUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked,
|
||||
systemUser);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
@ -1616,7 +1634,8 @@ public class OrganizationService : IOrganizationService
|
||||
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.");
|
||||
}
|
||||
@ -1664,7 +1683,8 @@ public class OrganizationService : IOrganizationService
|
||||
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.");
|
||||
}
|
||||
@ -1705,27 +1725,4 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted)
|
||||
{
|
||||
organization.Id = CoreHelpers.GenerateComb();
|
||||
organization.Enabled = false;
|
||||
organization.Status = OrganizationStatusType.Pending;
|
||||
|
||||
await SignUpAsync(organization, default, null, null, true);
|
||||
|
||||
var ownerOrganizationUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = null,
|
||||
Email = ownerEmail,
|
||||
Key = null,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
};
|
||||
await _organizationUserRepository.CreateAsync(ownerOrganizationUser);
|
||||
|
||||
await SendInviteAsync(ownerOrganizationUser, organization, true);
|
||||
await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited);
|
||||
}
|
||||
}
|
||||
|
114
src/Core/AdminConsole/Services/OrganizationFactory.cs
Normal file
114
src/Core/AdminConsole/Services/OrganizationFactory.cs
Normal 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,
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal file
83
src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
|
||||
{
|
||||
Task<int> DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration);
|
||||
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>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
|
||||
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
|
||||
|
@ -1,5 +1,9 @@
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Exceptions;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
#nullable enable
|
||||
|
||||
@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services;
|
||||
|
||||
public interface IAuthRequestService
|
||||
{
|
||||
Task<AuthRequest?> GetAuthRequestAsync(Guid id, Guid userId);
|
||||
Task<AuthRequest?> GetValidatedAuthRequestAsync(Guid id, string code);
|
||||
/// <summary>
|
||||
/// 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);
|
||||
/// <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);
|
||||
}
|
||||
|
@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService
|
||||
_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)
|
||||
{
|
||||
return null;
|
||||
@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService
|
||||
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);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code))
|
||||
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId);
|
||||
if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService
|
||||
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)
|
||||
{
|
||||
if (!_currentContext.DeviceType.HasValue)
|
||||
|
@ -38,7 +38,6 @@ public static class UserServiceCollectionExtensions
|
||||
|
||||
public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings)
|
||||
{
|
||||
services.AddScoped<IRotateUserKeyCommand, RotateUserKeyCommand>();
|
||||
services.AddScoped<IRotateUserAccountKeysCommand, RotateUserAccountKeysCommand>();
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,7 @@ public class OrganizationSale
|
||||
var subscriptionSetup = GetSubscriptionSetup(signup);
|
||||
|
||||
subscriptionSetup.SkipTrial = signup.SkipTrial;
|
||||
subscriptionSetup.InitiationPath = signup.InitiationPath;
|
||||
|
||||
return new OrganizationSale
|
||||
{
|
||||
|
@ -10,6 +10,7 @@ public class SubscriptionSetup
|
||||
public required PasswordManager PasswordManagerOptions { get; set; }
|
||||
public SecretsManager? SecretsManagerOptions { get; set; }
|
||||
public bool SkipTrial = false;
|
||||
public string? InitiationPath { get; set; }
|
||||
|
||||
public class PasswordManager
|
||||
{
|
||||
|
@ -420,12 +420,29 @@ public class OrganizationBillingService(
|
||||
Items = subscriptionItemOptionsList,
|
||||
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,
|
||||
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
|
||||
};
|
||||
|
||||
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
|
||||
if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) &&
|
||||
!customer.Metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
{
|
||||
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
|
||||
{
|
||||
EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions
|
||||
{
|
||||
MissingPaymentMethod = "cancel"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
|
@ -107,11 +107,10 @@ public static class FeatureFlagKeys
|
||||
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
|
||||
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
|
||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
|
||||
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
|
||||
public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript";
|
||||
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
|
||||
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
||||
|
||||
/* Auth Team */
|
||||
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
|
||||
@ -138,6 +137,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string InlineMenuTotp = "inline-menu-totp";
|
||||
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
|
||||
|
||||
/* Billing Team */
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
@ -181,6 +181,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
|
||||
public const string MobileErrorReporting = "mobile-error-reporting";
|
||||
public const string AndroidChromeAutofill = "android-chrome-autofill";
|
||||
public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
|
||||
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
||||
public const string AppIntents = "app-intents";
|
||||
|
||||
@ -192,7 +193,6 @@ public static class FeatureFlagKeys
|
||||
public const string IpcChannelFramework = "ipc-channel-framework";
|
||||
|
||||
/* Tools Team */
|
||||
public const string ItemShare = "item-share";
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
|
||||
/* Vault Team */
|
||||
@ -205,6 +205,8 @@ public static class FeatureFlagKeys
|
||||
public const string EndUserNotifications = "pm-10609-end-user-notifications";
|
||||
public const string PhishingDetection = "phishing-detection";
|
||||
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
|
||||
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
|
||||
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.79" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.136" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.0.10" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="4.0.0.11" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
|
||||
@ -59,7 +59,7 @@
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<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.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
|
20
src/Core/Dirt/Entities/OrganizationApplication.cs
Normal file
20
src/Core/Dirt/Entities/OrganizationApplication.cs
Normal file
@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class OrganizationApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Applications { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
20
src/Core/Dirt/Entities/OrganizationReport.cs
Normal file
20
src/Core/Dirt/Entities/OrganizationReport.cs
Normal file
@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class OrganizationReport : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string ReportData { get; set; } = string.Empty;
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Dirt.Reports.Entities;
|
||||
namespace Bit.Core.Dirt.Entities;
|
||||
|
||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
namespace Bit.Core.Dirt.Models.Data;
|
||||
|
||||
public class MemberAccessDetails
|
||||
{
|
19
src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs
Normal file
19
src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
public class MemberAccessReportDetail
|
||||
{
|
||||
public Guid? UserGuid { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
public bool AccountRecoveryEnabled { get; set; }
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public Guid? CollectionId { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string CollectionName { get; set; }
|
||||
public bool? ReadOnly { get; set; }
|
||||
public bool? HidePasswords { get; set; }
|
||||
public bool? Manage { get; set; }
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user