mirror of
https://github.com/bitwarden/server
synced 2026-02-12 14:33:49 +00:00
Merge branch 'main' into auth/pm-30810/http-redirect-cloud
This commit is contained in:
@@ -11,3 +11,7 @@ checkmarx:
|
||||
filter: "!test"
|
||||
kics:
|
||||
filter: "!dev,!.devcontainer"
|
||||
sca:
|
||||
filter: "!dev,!.devcontainer"
|
||||
containers:
|
||||
filter: "!dev,!.devcontainer"
|
||||
|
||||
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "bitwarden/ai-plugins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "9.0.4",
|
||||
"version": "10.1.0",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "16"
|
||||
}
|
||||
"version": "22"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
@@ -21,5 +23,27 @@
|
||||
"extensions": ["ms-dotnettools.csdevkit"]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh"
|
||||
"postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh",
|
||||
"forwardPorts": [1080, 1433, 3306, 5432],
|
||||
"portsAttributes": {
|
||||
"default": {
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"1080": {
|
||||
"label": "Mail Catcher",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"1433": {
|
||||
"label": "SQL Server",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3306": {
|
||||
"label": "MySQL",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev
|
||||
export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")"
|
||||
INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)"
|
||||
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
|
||||
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
|
||||
secrets.json.example >secrets.json # create/overwrite secrets.json
|
||||
@@ -30,11 +65,10 @@ configure_other_vars() {
|
||||
}
|
||||
|
||||
one_time_setup() {
|
||||
read -r -p \
|
||||
"Would you like to configure your secrets and certificates for the first time?
|
||||
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "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
|
||||
Proceed? [y/N] " "n")"
|
||||
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running one-time setup script..."
|
||||
sleep 1
|
||||
get_installation_id_and_key
|
||||
@@ -50,11 +84,4 @@ Proceed? [y/N] " response
|
||||
fi
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
],
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "16"
|
||||
}
|
||||
"version": "22"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
@@ -24,9 +26,18 @@
|
||||
"extensions": ["ms-dotnettools.csdevkit"]
|
||||
}
|
||||
},
|
||||
"onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh",
|
||||
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
|
||||
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002],
|
||||
"forwardPorts": [
|
||||
1080, 1433, 3306, 5432, 10000, 10001, 10002,
|
||||
4000, 4001, 33656, 33657, 44519, 44559,
|
||||
46273, 46274, 50024, 51822, 51823,
|
||||
54103, 61840, 61841, 62911, 62912
|
||||
],
|
||||
"portsAttributes": {
|
||||
"default": {
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"1080": {
|
||||
"label": "Mail Catcher",
|
||||
"onAutoForward": "notify"
|
||||
@@ -48,12 +59,76 @@
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"10001": {
|
||||
"label": "Azurite Storage Queue ",
|
||||
"label": "Azurite Storage Queue",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"10002": {
|
||||
"label": "Azurite Storage Table",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4000": {
|
||||
"label": "Api (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"4001": {
|
||||
"label": "Api (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"33656": {
|
||||
"label": "Identity (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"33657": {
|
||||
"label": "Identity (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"44519": {
|
||||
"label": "Billing",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"44559": {
|
||||
"label": "Scim",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"46273": {
|
||||
"label": "Events (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"46274": {
|
||||
"label": "Events (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"50024": {
|
||||
"label": "Icons",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"51822": {
|
||||
"label": "Sso (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"51823": {
|
||||
"label": "Sso (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"54103": {
|
||||
"label": "EventsProcessor",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"61840": {
|
||||
"label": "Notifications (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"61841": {
|
||||
"label": "Notifications (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"62911": {
|
||||
"label": "Admin (Cloud)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"62912": {
|
||||
"label": "Admin (SelfHost)",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
.devcontainer/internal_dev/onCreateCommand.sh
Normal file
12
.devcontainer/internal_dev/onCreateCommand.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
export REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
file="$REPO_ROOT/dev/custom-root-ca.crt"
|
||||
|
||||
if [ -e "$file" ]; then
|
||||
echo "Adding custom root CA"
|
||||
sudo cp "$file" /usr/local/share/ca-certificates/
|
||||
sudo update-ca-certificates
|
||||
else
|
||||
echo "No custom root CA found, skipping..."
|
||||
fi
|
||||
@@ -108,7 +108,7 @@ Press <Enter> to continue."
|
||||
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
|
||||
if [[ "$run_mssql_migrations" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running migrations..."
|
||||
sleep 5 # wait for DB container to start
|
||||
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"
|
||||
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -11,6 +11,9 @@
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
# Scanning tools
|
||||
.checkmarx/ @bitwarden/team-appsec
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/publish.yml @bitwarden/dept-bre
|
||||
|
||||
@@ -94,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
|
||||
.github/workflows/test-database.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/test.yml @bitwarden/team-platform-dev
|
||||
**/*Platform* @bitwarden/team-platform-dev
|
||||
**/.dockerignore @bitwarden/team-platform-dev
|
||||
**/Dockerfile @bitwarden/team-platform-dev
|
||||
**/entrypoint.sh @bitwarden/team-platform-dev
|
||||
|
||||
# The PushType enum is expected to be editted by anyone without need for Platform review
|
||||
src/Core/Platform/Push/PushType.cs
|
||||
|
||||
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,27 +9,3 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
14
.github/renovate.json5
vendored
14
.github/renovate.json5
vendored
@@ -21,12 +21,6 @@
|
||||
commitMessagePrefix: "[deps] AC:",
|
||||
reviewers: ["team:team-admin-console-dev"],
|
||||
},
|
||||
{
|
||||
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
|
||||
description: "Admin & SSO npm packages",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"DuoUniversal",
|
||||
@@ -182,6 +176,14 @@
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
groupName: "Admin and SSO npm dependencies",
|
||||
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
description: "Admin & SSO npm packages",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
|
||||
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@@ -119,10 +119,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@@ -263,14 +263,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
|
||||
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
@@ -420,7 +420,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test-database.yml
vendored
6
.github/workflows/test-database.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2026.1.0</Version>
|
||||
<Version>2026.2.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,6 +13,10 @@
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Scim</UserSecretsId>
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
@@ -45,7 +45,7 @@ public class AccountController : Controller
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IUserService _userService;
|
||||
private readonly II18nService _i18nService;
|
||||
private readonly UserManager<User> _userManager;
|
||||
@@ -67,7 +67,7 @@ public class AccountController : Controller
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IUserRepository userRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IUserService userService,
|
||||
II18nService i18nService,
|
||||
UserManager<User> userManager,
|
||||
@@ -88,7 +88,7 @@ public class AccountController : Controller
|
||||
_userRepository = userRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_ssoUserRepository = ssoUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_userService = userService;
|
||||
_i18nService = i18nService;
|
||||
_userManager = userManager;
|
||||
@@ -687,9 +687,8 @@ public class AccountController : Controller
|
||||
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy.Enabled)
|
||||
{
|
||||
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Sso</UserSecretsId>
|
||||
|
||||
@@ -8,7 +8,6 @@ using Bit.Core.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Sso;
|
||||
@@ -91,20 +90,15 @@ public class Startup
|
||||
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IWebHostEnvironment environment,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
if (env.IsDevelopment() || globalSettings.SelfHosted)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
if (!environment.IsDevelopment())
|
||||
{
|
||||
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
|
||||
app.Use(async (ctx, next) =>
|
||||
@@ -120,7 +114,7 @@ public class Startup
|
||||
app.UseForwardedHeaders(globalSettings);
|
||||
}
|
||||
|
||||
if (env.IsDevelopment())
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseCookiePolicy();
|
||||
|
||||
@@ -34,4 +34,5 @@ RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
|
||||
# SETUP_AZURITE=yes
|
||||
# RUN_MSSQL_MIGRATIONS=yes
|
||||
# DEV_CERT_PASSWORD=dev_cert_password_here
|
||||
# DEV_CERT_CONTENTS=base64_encoded_dev_pfx_here (alternative to placing dev.pfx file manually)
|
||||
# INSTALL_STRIPE_CLI=no
|
||||
|
||||
1
dev/.gitignore
vendored
1
dev/.gitignore
vendored
@@ -18,3 +18,4 @@ signingkey.jwk
|
||||
|
||||
# Reverse Proxy Conifg
|
||||
reverse-proxy.conf
|
||||
*.crt
|
||||
|
||||
@@ -77,6 +77,7 @@ services:
|
||||
- 4306:3306
|
||||
environment:
|
||||
MARIADB_USER: maria
|
||||
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
|
||||
MARIADB_DATABASE: vault_dev
|
||||
MARIADB_RANDOM_ROOT_PASSWORD: "true"
|
||||
volumes:
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
},
|
||||
"licenseDirectory": "<full path to license directory>",
|
||||
"enableNewDeviceVerification": true,
|
||||
"enableEmailVerification": true
|
||||
"enableEmailVerification": true,
|
||||
"communication": {
|
||||
"bootstrap": "none",
|
||||
"ssoCookieVendor": {
|
||||
"idpLoginUrl": "",
|
||||
"cookieName": "",
|
||||
"cookieDomain": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ $projects = @{
|
||||
Scim = "../bitwarden_license/src/Scim"
|
||||
IntegrationTests = "../test/Infrastructure.IntegrationTest"
|
||||
SeederApi = "../util/SeederApi"
|
||||
SeederUtility = "../util/DbSeederUtility"
|
||||
}
|
||||
|
||||
foreach ($key in $projects.keys) {
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
Validates that new database migration files follow naming conventions and chronological order.
|
||||
|
||||
.DESCRIPTION
|
||||
This script validates migration files in util/Migrator/DbScripts/ to ensure:
|
||||
This script validates migration files to ensure:
|
||||
|
||||
For SQL migrations in util/Migrator/DbScripts/:
|
||||
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
|
||||
2. New migrations are chronologically ordered (filename sorts after existing migrations)
|
||||
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
|
||||
4. A 2-digit sequence number is included (e.g., _00, _01)
|
||||
|
||||
For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
|
||||
1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
|
||||
2. Each migration has both .cs and .Designer.cs files
|
||||
3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
|
||||
|
||||
.PARAMETER BaseRef
|
||||
The base git reference to compare against (e.g., 'main', 'HEAD~1')
|
||||
|
||||
@@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/"
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
$sqlValidationFailed = $false
|
||||
|
||||
if ($addedMigrations.Count -eq 0) {
|
||||
Write-Host "No new migration files added."
|
||||
exit 0
|
||||
Write-Host "No new SQL migration files added."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host "New SQL migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last SQL migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
|
||||
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
|
||||
# Validate NEW migration filename format
|
||||
if ($migrationName -notmatch $formatRegex) {
|
||||
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " - YYYY: 4-digit year"
|
||||
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||
$sqlValidationFailed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration name with last base migration (using ordinal string comparison)
|
||||
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||
$sqlValidationFailed = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($sqlValidationFailed) {
|
||||
Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new SQL migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||
}
|
||||
else {
|
||||
Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "New migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
# ===========================================================================================
|
||||
# Validate Entity Framework Migrations
|
||||
# ===========================================================================================
|
||||
|
||||
Write-Host "==================================================================="
|
||||
Write-Host "Validating Entity Framework Migrations"
|
||||
Write-Host "==================================================================="
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous migrations found (initial commit?). Skipping validation."
|
||||
exit 0
|
||||
}
|
||||
$efMigrationPaths = @(
|
||||
@{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
|
||||
@{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
|
||||
@{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
|
||||
)
|
||||
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
$efValidationFailed = $false
|
||||
|
||||
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||
foreach ($migrationPathInfo in $efMigrationPaths) {
|
||||
$efPath = $migrationPathInfo.Path
|
||||
$dbName = $migrationPathInfo.Name
|
||||
|
||||
$validationFailed = $false
|
||||
Write-Host "-------------------------------------------------------------------"
|
||||
Write-Host "Checking $dbName EF migrations in $efPath"
|
||||
Write-Host "-------------------------------------------------------------------"
|
||||
Write-Host ""
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
# Get list of migrations from base reference
|
||||
try {
|
||||
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
|
||||
$baseMigrations = @()
|
||||
}
|
||||
|
||||
# Validate NEW migration filename format
|
||||
if ($migrationName -notmatch $formatRegex) {
|
||||
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " - YYYY: 4-digit year"
|
||||
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||
$validationFailed = $true
|
||||
# Get list of migrations from current reference
|
||||
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
|
||||
|
||||
# Find added migrations
|
||||
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||
|
||||
if ($addedMigrations.Count -eq 0) {
|
||||
Write-Host "No new $dbName EF migration files added."
|
||||
Write-Host ""
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration name with last base migration (using ordinal string comparison)
|
||||
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||
$validationFailed = $true
|
||||
Write-Host "New $dbName EF migration files detected:"
|
||||
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Get the last migration from base reference
|
||||
if ($baseMigrations.Count -eq 0) {
|
||||
Write-Host "No previous $dbName migrations found. Skipping chronological validation."
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||
Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
|
||||
$efFormatRegex = '^[0-9]{14}_.+\.cs$'
|
||||
|
||||
# Group migrations by base name (without .Designer.cs suffix)
|
||||
$migrationGroups = @{}
|
||||
$unmatchedFiles = @()
|
||||
|
||||
foreach ($migration in $addedMigrations) {
|
||||
$migrationName = Split-Path -Leaf $migration
|
||||
|
||||
# Extract base name (remove .Designer.cs or .cs)
|
||||
if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
|
||||
$baseName = $matches[1]
|
||||
if (-not $migrationGroups.ContainsKey($baseName)) {
|
||||
$migrationGroups[$baseName] = @()
|
||||
}
|
||||
$migrationGroups[$baseName] += $migrationName
|
||||
}
|
||||
else {
|
||||
# Track files that don't match the expected pattern
|
||||
$unmatchedFiles += $migrationName
|
||||
}
|
||||
}
|
||||
|
||||
# Flag any files that don't match the expected pattern
|
||||
if ($unmatchedFiles.Count -gt 0) {
|
||||
Write-Host "ERROR: The following migration files do not match the required format:"
|
||||
foreach ($unmatchedFile in $unmatchedFiles) {
|
||||
Write-Host " - $unmatchedFile"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
|
||||
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
|
||||
Write-Host " - Description: Descriptive name using PascalCase"
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
|
||||
Write-Host ""
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
|
||||
$files = $migrationGroups[$baseName]
|
||||
|
||||
# Validate format
|
||||
$mainFile = "$baseName.cs"
|
||||
$designerFile = "$baseName.Designer.cs"
|
||||
|
||||
if ($mainFile -notmatch $efFormatRegex) {
|
||||
Write-Host "ERROR: Migration '$mainFile' does not match required format"
|
||||
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs"
|
||||
$efValidationFailed = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Check that both .cs and .Designer.cs files exist
|
||||
$hasCsFile = $files -contains $mainFile
|
||||
$hasDesignerFile = $files -contains $designerFile
|
||||
|
||||
if (-not $hasCsFile) {
|
||||
Write-Host "ERROR: Missing main migration file: $mainFile"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
if (-not $hasDesignerFile) {
|
||||
Write-Host "ERROR: Missing designer file: $designerFile"
|
||||
Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
|
||||
if (-not $hasCsFile -or -not $hasDesignerFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Compare migration timestamp with last base migration (using ordinal string comparison)
|
||||
if ($baseMigrations.Count -gt 0) {
|
||||
if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
|
||||
Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
|
||||
$efValidationFailed = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "OK: '$mainFile' (no previous migrations to compare)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($efValidationFailed) {
|
||||
Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new EF migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " 2. Include both .cs and .Designer.cs files"
|
||||
Write-Host " 3. Have a timestamp that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in the respective Migrations directory"
|
||||
Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
|
||||
Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
|
||||
Write-Host " 4. Ensure the timestamp is after the last migration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
|
||||
}
|
||||
else {
|
||||
Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================================="
|
||||
Write-Host "Validation Summary"
|
||||
Write-Host "==================================================================="
|
||||
|
||||
if ($sqlValidationFailed -or $efValidationFailed) {
|
||||
if ($sqlValidationFailed) {
|
||||
Write-Host "❌ SQL migrations validation FAILED"
|
||||
}
|
||||
else {
|
||||
Write-Host "✓ SQL migrations validation PASSED"
|
||||
}
|
||||
|
||||
if ($efValidationFailed) {
|
||||
Write-Host "❌ EF migrations validation FAILED"
|
||||
}
|
||||
else {
|
||||
Write-Host "✓ EF migrations validation PASSED"
|
||||
}
|
||||
|
||||
if ($validationFailed) {
|
||||
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
|
||||
Write-Host ""
|
||||
Write-Host "All new migration files must:"
|
||||
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||
Write-Host ""
|
||||
Write-Host "To fix this issue:"
|
||||
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||
Write-Host ""
|
||||
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||
Write-Host "OVERALL RESULT: FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
|
||||
exit 0
|
||||
else {
|
||||
Write-Host "✓ SQL migrations validation PASSED"
|
||||
Write-Host "✓ EF migrations validation PASSED"
|
||||
Write-Host ""
|
||||
Write-Host "OVERALL RESULT: SUCCESS"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0",
|
||||
"Microsoft.Build.Sql": "1.0.0",
|
||||
"Bitwarden.Server.Sdk": "1.2.0"
|
||||
"Bitwarden.Server.Sdk": "1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Admin</UserSecretsId>
|
||||
|
||||
@@ -86,7 +86,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
|
||||
|
||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);
|
||||
|
||||
@@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.ConfigureKestrel(o =>
|
||||
|
||||
@@ -20,11 +20,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
@@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
@@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
ICollectionRepository collectionRepository,
|
||||
IGroupRepository groupRepository,
|
||||
IUserService userService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
ICurrentContext currentContext,
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
@@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
_collectionRepository = collectionRepository;
|
||||
_groupRepository = groupRepository;
|
||||
_userService = userService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_currentContext = currentContext;
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
@@ -155,7 +155,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<OrganizationUserDetailsResponseModel> Get(Guid orgId, Guid id, bool includeGroups = false)
|
||||
{
|
||||
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
|
||||
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithSharedCollectionsAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != orgId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@@ -287,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
|
||||
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
|
||||
{
|
||||
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
}
|
||||
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
|
||||
|
||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(
|
||||
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
|
||||
@@ -331,6 +324,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != orgId)
|
||||
{
|
||||
throw new NotFoundException("Organization user mismatch");
|
||||
}
|
||||
|
||||
var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
? (await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id)).AutoEnrollEnabled(orgId)
|
||||
: await ShouldHandleResetPasswordAsync(orgId);
|
||||
@@ -357,10 +356,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
return false;
|
||||
}
|
||||
|
||||
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
|
||||
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
|
||||
masterPasswordPolicy.Enabled &&
|
||||
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
|
||||
var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
|
||||
var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
|
||||
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
|
||||
|
||||
return useMasterPasswordPolicy;
|
||||
}
|
||||
@@ -697,7 +695,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task RestoreAsync(Guid orgId, Guid id)
|
||||
{
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null));
|
||||
}
|
||||
|
||||
|
||||
[HttpPut("{id}/restore/vnext")]
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
[RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)]
|
||||
public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request)
|
||||
{
|
||||
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/restore")]
|
||||
@@ -712,7 +719,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model,
|
||||
(orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds,
|
||||
restoringUserId, _userService, model.DefaultUserCollectionName));
|
||||
}
|
||||
|
||||
[HttpPatch("restore")]
|
||||
|
||||
@@ -48,7 +48,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
@@ -74,7 +74,7 @@ public class OrganizationsController : Controller
|
||||
public OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrganizationService organizationService,
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext,
|
||||
@@ -99,7 +99,7 @@ public class OrganizationsController : Controller
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_organizationService = organizationService;
|
||||
_userService = userService;
|
||||
_currentContext = currentContext;
|
||||
@@ -183,15 +183,14 @@ public class OrganizationsController : Controller
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
|
||||
}
|
||||
|
||||
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
|
||||
{
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
|
||||
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
|
||||
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
|
||||
@@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
@@ -43,6 +42,7 @@ public class PoliciesController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
|
||||
public PoliciesController(IPolicyRepository policyRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -54,7 +54,8 @@ public class PoliciesController : Controller
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISavePolicyCommand savePolicyCommand,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand,
|
||||
IPolicyQuery policyQuery)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -68,27 +69,24 @@ public class PoliciesController : Controller
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
_savePolicyCommand = savePolicyCommand;
|
||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||
_policyQuery = policyQuery;
|
||||
}
|
||||
|
||||
[HttpGet("{type}")]
|
||||
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||
public async Task<PolicyStatusResponseModel> Get(Guid orgId, PolicyType type)
|
||||
{
|
||||
if (!await _currentContext.ManagePolicies(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||
if (policy == null)
|
||||
{
|
||||
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||
}
|
||||
|
||||
var policy = await _policyQuery.RunAsync(orgId, type);
|
||||
if (policy.Type is PolicyType.SingleOrg)
|
||||
{
|
||||
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||
return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||
}
|
||||
|
||||
return new PolicyDetailResponseModel(policy);
|
||||
return new PolicyStatusResponseModel(policy);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request;
|
||||
|
||||
public class OrganizationDomainRequestModel
|
||||
{
|
||||
[Required]
|
||||
[DomainNameValidator]
|
||||
public string DomainName { get; set; }
|
||||
}
|
||||
|
||||
@@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
public string ResetPasswordKey { get; set; }
|
||||
public string MasterPasswordHash { get; set; }
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
public class OrganizationUserBulkRequestModel
|
||||
{
|
||||
[Required, MinLength(1)]
|
||||
public IEnumerable<Guid> Ids { get; set; }
|
||||
public IEnumerable<Guid> Ids { get; set; } = new List<Guid>();
|
||||
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string? DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationUserRestoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// This is the encrypted default collection name to be used for restored users if required
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string? DefaultUserCollectionName { get; set; }
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
|
||||
public static class PolicyDetailResponses
|
||||
public static class PolicyStatusResponses
|
||||
{
|
||||
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||
public static async Task<PolicyStatusResponseModel> GetSingleOrgPolicyStatusResponseAsync(
|
||||
this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||
{
|
||||
if (policy.Type is not PolicyType.SingleOrg)
|
||||
{
|
||||
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||
}
|
||||
return new PolicyDetailResponseModel(policy, await CanToggleState());
|
||||
|
||||
return new PolicyStatusResponseModel(policy, await CanToggleState());
|
||||
|
||||
async Task<bool> CanToggleState()
|
||||
{
|
||||
@@ -25,5 +27,4 @@ public static class PolicyDetailResponses
|
||||
return !policy.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||
{
|
||||
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||
{
|
||||
CanToggleState = canToggleState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the Policy can be enabled/disabled
|
||||
/// </summary>
|
||||
public bool CanToggleState { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class PolicyStatusResponseModel : ResponseModel
|
||||
{
|
||||
public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy")
|
||||
{
|
||||
OrganizationId = policy.OrganizationId;
|
||||
Type = policy.Type;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policy.Data))
|
||||
{
|
||||
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data) ?? new();
|
||||
}
|
||||
|
||||
Enabled = policy.Enabled;
|
||||
CanToggleState = canToggleState;
|
||||
}
|
||||
|
||||
public Guid OrganizationId { get; init; }
|
||||
public PolicyType Type { get; init; }
|
||||
public Dictionary<string, object> Data { get; init; } = new();
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the Policy can be enabled/disabled
|
||||
/// </summary>
|
||||
public bool CanToggleState { get; init; }
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -30,6 +34,8 @@ public class MembersController : Controller
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
|
||||
public MembersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -42,7 +48,9 @@ public class MembersController : Controller
|
||||
IOrganizationRepository organizationRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_groupRepository = groupRepository;
|
||||
@@ -55,6 +63,8 @@ public class MembersController : Controller
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,7 +80,7 @@ public class MembersController : Controller
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
|
||||
var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithSharedCollectionsAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
@@ -113,7 +123,7 @@ public class MembersController : Controller
|
||||
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true);
|
||||
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeSharedCollections: true);
|
||||
|
||||
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
|
||||
var memberResponses = organizationUserUserDetails.Select(u =>
|
||||
@@ -258,4 +268,59 @@ public class MembersController : Controller
|
||||
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a member's access to an organization.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the member to be revoked.</param>
|
||||
[HttpPost("{id}/revoke")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Revoke(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
var request = new RevokeOrganizationUsersRequest(
|
||||
_currentContext.OrganizationId!.Value,
|
||||
[id],
|
||||
new SystemUser(EventSystemUser.PublicApi)
|
||||
);
|
||||
|
||||
var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
|
||||
var result = results.Single();
|
||||
|
||||
return result.Result.Match<IActionResult>(
|
||||
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
|
||||
_ => new OkResult()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Restores a previously revoked member of the organization.
|
||||
/// </remarks>
|
||||
/// <param name="id">The identifier of the member to be restored.</param>
|
||||
[HttpPost("{id}/restore")]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Restore(Guid id)
|
||||
{
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
@@ -36,7 +38,7 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="5.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Jobs;
|
||||
using Quartz;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
|
||||
using Bit.Core.Jobs;
|
||||
using Quartz;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.KeyManagement.Models.Requests;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Request.Accounts;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.KeyManagement.Models.Requests;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
@@ -112,6 +112,7 @@ public class EmergencyAccessTakeoverResponseModel : ResponseModel
|
||||
KdfIterations = grantor.KdfIterations;
|
||||
KdfMemory = grantor.KdfMemory;
|
||||
KdfParallelism = grantor.KdfParallelism;
|
||||
Salt = grantor.GetMasterPasswordSalt();
|
||||
}
|
||||
|
||||
public int KdfIterations { get; private set; }
|
||||
@@ -119,6 +120,7 @@ public class EmergencyAccessTakeoverResponseModel : ResponseModel
|
||||
public int? KdfParallelism { get; private set; }
|
||||
public KdfType Kdf { get; private set; }
|
||||
public string KeyEncrypted { get; private set; }
|
||||
public string Salt { get; private set; }
|
||||
}
|
||||
|
||||
public class EmergencyAccessViewResponseModel : ResponseModel
|
||||
|
||||
@@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Api.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationSponsorshipsController(
|
||||
@@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
|
||||
IUserService userService,
|
||||
ICurrentContext currentContext,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationSponsorshipRepository = organizationSponsorshipRepository;
|
||||
@@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
|
||||
_syncSponsorshipsCommand = syncSponsorshipsCommand;
|
||||
_userService = userService;
|
||||
_currentContext = currentContext;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
|
||||
{
|
||||
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
@@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
|
||||
{
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
@@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller
|
||||
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
|
||||
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
|
||||
{
|
||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||
var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
|
||||
isFreeFamilyPolicyEnabled = policy.Enabled;
|
||||
}
|
||||
|
||||
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
|
||||
@@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
|
||||
}
|
||||
|
||||
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
||||
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
|
||||
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
|
||||
|
||||
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
|
||||
if (freeFamiliesSponsorshipPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Tax;
|
||||
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Organizations.Commands;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
@@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Authorize("Application")]
|
||||
[Route("billing/tax")]
|
||||
public class TaxController(
|
||||
[Route("billing/preview-invoice")]
|
||||
public class PreviewInvoiceController(
|
||||
IPreviewOrganizationTaxCommand previewOrganizationTaxCommand,
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand,
|
||||
IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("organizations/subscriptions/purchase")]
|
||||
public async Task<IResult> PreviewOrganizationSubscriptionPurchaseTaxAsync(
|
||||
@@ -21,11 +23,7 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
|
||||
@@ -36,11 +34,7 @@ public class TaxController(
|
||||
{
|
||||
var (planChange, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPut("organizations/{organizationId:guid}/subscription/update")]
|
||||
@@ -51,11 +45,7 @@ public class TaxController(
|
||||
{
|
||||
var update = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, update);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/purchase")]
|
||||
@@ -64,10 +54,29 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/upgrade")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] PreviewPremiumUpgradeProrationRequest request)
|
||||
{
|
||||
var (planType, billingAddress) = request.ToDomain();
|
||||
|
||||
var result = await previewPremiumUpgradeProrationCommand.Run(
|
||||
user,
|
||||
planType,
|
||||
billingAddress);
|
||||
|
||||
return Handle(result.Map(proration => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
proration.NewPlanProratedAmount,
|
||||
proration.Credit,
|
||||
proration.Tax,
|
||||
proration.Total,
|
||||
proration.NewPlanProratedMonths
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -132,8 +132,8 @@ public class AccountBillingVNextController(
|
||||
[BindNever] User user,
|
||||
[FromBody] UpgradePremiumToOrganizationRequest request)
|
||||
{
|
||||
var (organizationName, key, planType) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
|
||||
var (organizationName, key, planType, billingAddress) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||
@@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public ProductTierType Tier { get; set; }
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PlanCadenceType Cadence { get; set; }
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType =>
|
||||
Tier switch
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.TeamsMonthly
|
||||
: PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.EnterpriseMonthly
|
||||
: PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
|
||||
};
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
|
||||
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPlanChangeTaxRequest
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Organizations;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public class PreviewOrganizationSubscriptionUpdateTaxRequest
|
||||
{
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumUpgradeProrationRequest
|
||||
{
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (PlanType, BillingAddress) ToDomain() =>
|
||||
(PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
@@ -81,7 +81,7 @@ public class CollectionsController : Controller
|
||||
[HttpGet("details")]
|
||||
public async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails(Guid orgId)
|
||||
{
|
||||
var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(
|
||||
var allOrgCollections = await _collectionRepository.GetManySharedByOrganizationIdWithPermissionsAsync(
|
||||
orgId, _currentContext.UserId.Value, true);
|
||||
|
||||
var readAllAuthorized =
|
||||
|
||||
119
src/Api/Controllers/SsoCookieVendorController.cs
Normal file
119
src/Api/Controllers/SsoCookieVendorController.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an endpoint to read an SSO cookie and redirect to a custom URI
|
||||
/// scheme. The load balancer/reverse proxy must be configured such that
|
||||
/// requests to this endpoint do not have the auth cookie stripped.
|
||||
/// </summary>
|
||||
[Route("sso-cookie-vendor")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
public class SsoCookieVendorController(IGlobalSettings globalSettings) : Controller
|
||||
{
|
||||
private readonly IGlobalSettings _globalSettings = globalSettings;
|
||||
private const int _maxShardCount = 20;
|
||||
private const int _maxUriLength = 8192;
|
||||
|
||||
/// <summary>
|
||||
/// Reads SSO cookie (shards supported) and redirects to the bitwarden://
|
||||
/// URI with cookie value(s).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 302 redirect on success, 404 if no cookies found, 400 if URI too long,
|
||||
/// 500 if misconfigured
|
||||
/// </returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Get()
|
||||
{
|
||||
var bootstrap = _globalSettings.Communication?.Bootstrap;
|
||||
if (string.IsNullOrEmpty(bootstrap) || !bootstrap.Equals("ssoCookieVendor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var cookieName = _globalSettings.Communication?.SsoCookieVendor?.CookieName;
|
||||
if (string.IsNullOrWhiteSpace(cookieName))
|
||||
{
|
||||
return StatusCode(500, "SSO cookie vendor is not properly configured");
|
||||
}
|
||||
|
||||
var uri = string.Empty;
|
||||
if (TryGetCookie(cookieName, out var cookie))
|
||||
{
|
||||
uri = BuildRedirectUri(cookie);
|
||||
}
|
||||
else if (TryGetShardedCookie(cookieName, out var shardedCookie))
|
||||
{
|
||||
uri = BuildRedirectUri(shardedCookie);
|
||||
}
|
||||
|
||||
if (uri == string.Empty)
|
||||
{
|
||||
return NotFound("No SSO cookies found");
|
||||
}
|
||||
|
||||
if (uri.Length > _maxUriLength)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
return Redirect(uri);
|
||||
}
|
||||
|
||||
private bool TryGetCookie(string cookieName, out Dictionary<string, string> cookie)
|
||||
{
|
||||
cookie = [];
|
||||
|
||||
if (Request.Cookies.TryGetValue(cookieName, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
cookie[cookieName] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetShardedCookie(string cookieName, out Dictionary<string, string> cookies)
|
||||
{
|
||||
var shardedCookies = new Dictionary<string, string>();
|
||||
|
||||
for (var i = 0; i < _maxShardCount; i++)
|
||||
{
|
||||
var shardName = $"{cookieName}-{i}";
|
||||
if (Request.Cookies.TryGetValue(shardName, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
shardedCookies[shardName] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stop at first missing shard to maintain order integrity
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cookies = shardedCookies;
|
||||
return shardedCookies.Count > 0;
|
||||
}
|
||||
|
||||
private static string BuildRedirectUri(Dictionary<string, string> cookies)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
foreach (var kvp in cookies)
|
||||
{
|
||||
var encodedValue = Uri.EscapeDataString(kvp.Value);
|
||||
queryParams.Add($"{kvp.Key}={encodedValue}");
|
||||
}
|
||||
|
||||
// Add a sentinel value so clients can detect a truncated URI, in the
|
||||
// event a user agent decides the URI is too long.
|
||||
queryParams.Add("d=1");
|
||||
|
||||
return $"bitwarden://sso_cookie_vendor?{string.Join("&", queryParams)}";
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
|
||||
}
|
||||
|
||||
// Exclude any account recovery that do not have a key.
|
||||
existing = existing.Where(o => o.ResetPasswordKey != null).ToList();
|
||||
|
||||
existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
|
||||
|
||||
foreach (var ou in existing)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
@@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, string errorValue)
|
||||
: this(errorKey, new string[] { errorValue })
|
||||
: this(errorKey, [errorValue])
|
||||
{ }
|
||||
|
||||
public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues)
|
||||
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
|
||||
{ }
|
||||
|
||||
[JsonConstructor]
|
||||
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
|
||||
{
|
||||
Message = message;
|
||||
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
|
||||
/// </summary>
|
||||
/// <example>The request model is invalid.</example>
|
||||
[Required]
|
||||
public string Message { get; set; }
|
||||
public string Message { get; init; }
|
||||
/// <summary>
|
||||
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
|
||||
/// request parameter will include a dictionary key describing that parameter.
|
||||
/// </summary>
|
||||
public Dictionary<string, IEnumerable<string>> Errors { get; set; }
|
||||
public Dictionary<string, IEnumerable<string>>? Errors { get; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public class ConfigResponseModel : ResponseModel
|
||||
public EnvironmentConfigResponseModel Environment { get; set; }
|
||||
public IDictionary<string, object> FeatureStates { get; set; }
|
||||
public PushSettings Push { get; set; }
|
||||
public CommunicationSettings Communication { get; set; }
|
||||
public ServerSettingsResponseModel Settings { get; set; }
|
||||
|
||||
public ConfigResponseModel() : base("config")
|
||||
@@ -48,6 +49,7 @@ public class ConfigResponseModel : ResponseModel
|
||||
FeatureStates = featureService.GetAll();
|
||||
var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
|
||||
Push = PushSettings.Build(webPushEnabled, globalSettings);
|
||||
Communication = CommunicationSettings.Build(globalSettings);
|
||||
Settings = new ServerSettingsResponseModel
|
||||
{
|
||||
DisableUserRegistration = globalSettings.DisableUserRegistration
|
||||
@@ -88,6 +90,40 @@ public class PushSettings
|
||||
}
|
||||
}
|
||||
|
||||
public class CommunicationSettings
|
||||
{
|
||||
public CommunicationBootstrapSettings Bootstrap { get; private init; }
|
||||
|
||||
public static CommunicationSettings Build(IGlobalSettings globalSettings)
|
||||
{
|
||||
var bootstrap = CommunicationBootstrapSettings.Build(globalSettings);
|
||||
return bootstrap == null ? null : new() { Bootstrap = bootstrap };
|
||||
}
|
||||
}
|
||||
|
||||
public class CommunicationBootstrapSettings
|
||||
{
|
||||
public string Type { get; private init; }
|
||||
public string IdpLoginUrl { get; private init; }
|
||||
public string CookieName { get; private init; }
|
||||
public string CookieDomain { get; private init; }
|
||||
|
||||
public static CommunicationBootstrapSettings Build(IGlobalSettings globalSettings)
|
||||
{
|
||||
return globalSettings.Communication?.Bootstrap?.ToLowerInvariant() switch
|
||||
{
|
||||
"ssocookievendor" => new()
|
||||
{
|
||||
Type = "ssoCookieVendor",
|
||||
IdpLoginUrl = globalSettings.Communication?.SsoCookieVendor?.IdpLoginUrl,
|
||||
CookieName = globalSettings.Communication?.SsoCookieVendor?.CookieName,
|
||||
CookieDomain = globalSettings.Communication?.SsoCookieVendor?.CookieDomain
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerSettingsResponseModel
|
||||
{
|
||||
public bool DisableUserRegistration { get; set; }
|
||||
|
||||
@@ -8,7 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.ConfigureCustomAppConfiguration(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@@ -67,8 +67,9 @@ public class CollectionsController : Controller
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
|
||||
|
||||
var collectionResponses = collections.Select(c =>
|
||||
new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
var collectionResponses = collections
|
||||
.Where(c => c.Item1.Type != CollectionType.DefaultUserCollection)
|
||||
.Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
|
||||
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
|
||||
return new JsonResult(response);
|
||||
|
||||
@@ -14,8 +14,7 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.SharedWeb.Health;
|
||||
using Microsoft.IdentityModel.Logging;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
@@ -238,8 +237,6 @@ public class Startup
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<Startup> logger)
|
||||
{
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
@@ -304,44 +301,43 @@ public class Startup
|
||||
// Remove all Bitwarden cloud servers and only register the local server
|
||||
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
|
||||
{
|
||||
swaggerDoc.Servers.Clear();
|
||||
swaggerDoc.Servers.Add(new OpenApiServer
|
||||
{
|
||||
Url = globalSettings.BaseServiceUri.Api,
|
||||
});
|
||||
swaggerDoc.Servers =
|
||||
[
|
||||
new() {
|
||||
Url = globalSettings.BaseServiceUri.Api,
|
||||
}
|
||||
];
|
||||
|
||||
swaggerDoc.Components.SecuritySchemes.Clear();
|
||||
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
|
||||
swaggerDoc.Components ??= new OpenApiComponents();
|
||||
swaggerDoc.Components.SecuritySchemes = new Dictionary<string, IOpenApiSecurityScheme>
|
||||
{
|
||||
Type = SecuritySchemeType.OAuth2,
|
||||
Flows = new OpenApiOAuthFlows
|
||||
{
|
||||
ClientCredentials = new OpenApiOAuthFlow
|
||||
"oauth2-client-credentials",
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
|
||||
Scopes = new Dictionary<string, string>
|
||||
Type = SecuritySchemeType.OAuth2,
|
||||
Flows = new OpenApiOAuthFlows
|
||||
{
|
||||
ClientCredentials = new OpenApiOAuthFlow
|
||||
{
|
||||
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
|
||||
Scopes = new Dictionary<string, string>
|
||||
{
|
||||
{ ApiScopes.ApiOrganization, "Organization APIs" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
swaggerDoc.SecurityRequirements.Clear();
|
||||
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
swaggerDoc.Security =
|
||||
[
|
||||
new OpenApiSecurityRequirement
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "oauth2-client-credentials"
|
||||
}
|
||||
},
|
||||
[ApiScopes.ApiOrganization]
|
||||
}
|
||||
});
|
||||
[new OpenApiSecuritySchemeReference("oauth2-client-credentials")] = [ApiScopes.ApiOrganization]
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
|
||||
throw new BadRequestException("You cannot import this much data at once.");
|
||||
}
|
||||
|
||||
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
|
||||
{
|
||||
throw new BadRequestException("You cannot import archived items into an organization.");
|
||||
}
|
||||
|
||||
var orgId = new Guid(organizationId);
|
||||
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();
|
||||
|
||||
|
||||
@@ -239,9 +239,8 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
|
||||
if (!INonAnonymousSendCommand.SendCanBeAccessed(send))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@@ -253,9 +252,19 @@ public class SendsController : Controller
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
/*
|
||||
* AccessCount is incremented differently for File and Text Send types:
|
||||
* - Text Sends are incremented at every access
|
||||
* - File Sends are incremented only when the file is downloaded
|
||||
*
|
||||
* Note that this endpoint is initially called for all Send types
|
||||
*/
|
||||
if (send.Type == SendType.Text)
|
||||
{
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
}
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
@@ -272,19 +281,14 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
|
||||
var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
if (result.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
|
||||
}
|
||||
|
||||
@@ -399,19 +403,7 @@ public class SendsController : Controller
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send);
|
||||
return await this.PutRemoveAuth(id);
|
||||
}
|
||||
|
||||
// Removes ALL authentication (email or password) if any is present
|
||||
|
||||
@@ -7,7 +7,7 @@ using Bit.SharedWeb.Health;
|
||||
using Bit.SharedWeb.Swagger;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
|
||||
namespace Bit.Api.Utilities;
|
||||
|
||||
|
||||
@@ -976,14 +976,14 @@ public class CiphersController : Controller
|
||||
public async Task DeleteAdmin(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await GetByIdAsync(id, userId);
|
||||
var cipher = await GetByIdAsyncAdmin(id);
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _cipherService.DeleteAsync(cipher, userId, true);
|
||||
await _cipherService.DeleteAsync(new CipherDetails(cipher), userId, true);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/delete-admin")]
|
||||
|
||||
@@ -6,6 +6,7 @@ using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
@@ -44,6 +45,7 @@ public class SyncController : Controller
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
|
||||
public SyncController(
|
||||
@@ -61,6 +63,7 @@ public class SyncController : Controller
|
||||
IFeatureService featureService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IWebAuthnCredentialRepository webAuthnCredentialRepository,
|
||||
IUserAccountKeysQuery userAccountKeysQuery)
|
||||
{
|
||||
_userService = userService;
|
||||
@@ -77,6 +80,7 @@ public class SyncController : Controller
|
||||
_featureService = featureService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_webAuthnCredentialRepository = webAuthnCredentialRepository;
|
||||
_userAccountKeysQuery = userAccountKeysQuery;
|
||||
}
|
||||
|
||||
@@ -120,6 +124,9 @@ public class SyncController : Controller
|
||||
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
|
||||
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock)
|
||||
? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)
|
||||
: [];
|
||||
|
||||
UserAccountKeysData userAccountKeys = null;
|
||||
// JIT TDE users and some broken/old users may not have a private key.
|
||||
@@ -130,7 +137,7 @@ public class SyncController : Controller
|
||||
|
||||
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
|
||||
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
@@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
IDictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersDict,
|
||||
bool excludeDomains,
|
||||
IEnumerable<Policy> policies,
|
||||
IEnumerable<Send> sends)
|
||||
IEnumerable<Send> sends,
|
||||
IEnumerable<WebAuthnCredential> webAuthnCredentials)
|
||||
: this()
|
||||
{
|
||||
Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
|
||||
@@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
|
||||
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
|
||||
Sends = sends.Select(s => new SendResponseModel(s));
|
||||
var webAuthnPrfOptions = webAuthnCredentials
|
||||
.Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
|
||||
.Select(c => new WebAuthnPrfDecryptionOption(
|
||||
c.EncryptedPrivateKey,
|
||||
c.EncryptedUserKey,
|
||||
c.CredentialId,
|
||||
[] // transports as empty array
|
||||
))
|
||||
.ToArray();
|
||||
|
||||
UserDecryption = new UserDecryptionResponseModel
|
||||
{
|
||||
MasterPasswordUnlock = user.HasMasterPassword()
|
||||
@@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
MasterKeyEncryptedUserKey = user.Key!,
|
||||
Salt = user.Email.ToLowerInvariant()
|
||||
}
|
||||
: null
|
||||
: null,
|
||||
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MarkDig" Version="0.44.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class StripeEventService(
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<StripeEventService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
@@ -148,26 +149,36 @@ public class StripeEventService(
|
||||
{
|
||||
var setupIntent = await GetSetupIntent(localStripeEvent);
|
||||
|
||||
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
|
||||
|
||||
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
|
||||
|
||||
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
|
||||
|
||||
if (subscriberId == null)
|
||||
{
|
||||
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
|
||||
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
|
||||
if (organization is { GatewayCustomerId: not null })
|
||||
{
|
||||
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
|
||||
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
|
||||
return organizationCustomer.Metadata;
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
|
||||
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
|
||||
if (provider is not { GatewayCustomerId: not null })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
|
||||
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
|
||||
return providerCustomer.Metadata;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
using Stripe.TestHelpers;
|
||||
using static Bit.Core.Billing.Constants.StripeConstants;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
@@ -25,14 +23,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly IOrganizationEnableCommand _organizationEnableCommand;
|
||||
private readonly IOrganizationDisableCommand _organizationDisableCommand;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
|
||||
private readonly IPushNotificationAdapter _pushNotificationAdapter;
|
||||
|
||||
public SubscriptionUpdatedHandler(
|
||||
@@ -43,14 +38,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISchedulerFactory schedulerFactory,
|
||||
IOrganizationEnableCommand organizationEnableCommand,
|
||||
IOrganizationDisableCommand organizationDisableCommand,
|
||||
IPricingClient pricingClient,
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderService providerService,
|
||||
ILogger<SubscriptionUpdatedHandler> logger,
|
||||
IPushNotificationAdapter pushNotificationAdapter)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
@@ -62,183 +54,147 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_providerRepository = providerRepository;
|
||||
_schedulerFactory = schedulerFactory;
|
||||
_organizationEnableCommand = organizationEnableCommand;
|
||||
_organizationDisableCommand = organizationDisableCommand;
|
||||
_pricingClient = pricingClient;
|
||||
_featureService = featureService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerService = providerService;
|
||||
_logger = logger;
|
||||
_pushNotificationAdapter = pushNotificationAdapter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
SubscriberId subscriberId = subscription;
|
||||
|
||||
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
|
||||
|
||||
switch (subscription.Status)
|
||||
if (SubscriptionWentUnpaid(parsedEvent, subscription))
|
||||
{
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
|
||||
when organizationId.HasValue:
|
||||
{
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
|
||||
if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
|
||||
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
|
||||
{
|
||||
await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when providerId.HasValue:
|
||||
{
|
||||
await HandleUnpaidProviderSubscriptionAsync(providerId.Value, parsedEvent, subscription);
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
|
||||
{
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (await IsPremiumSubscriptionAsync(subscription))
|
||||
{
|
||||
await CancelSubscription(subscription.Id);
|
||||
await VoidOpenInvoices(subscription.Id);
|
||||
}
|
||||
|
||||
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
|
||||
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Incomplete when userId.HasValue:
|
||||
{
|
||||
// Handle Incomplete subscriptions for Premium users that have open invoices from failed payments
|
||||
// This prevents duplicate subscriptions when users retry the subscription flow
|
||||
if (await IsPremiumSubscriptionAsync(subscription) &&
|
||||
subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open })
|
||||
{
|
||||
await CancelSubscription(subscription.Id);
|
||||
await VoidOpenInvoices(subscription.Id);
|
||||
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active when organizationId.HasValue:
|
||||
{
|
||||
await _organizationEnableCommand.EnableAsync(organizationId.Value);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
if (organization != null)
|
||||
{
|
||||
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
if (provider != null)
|
||||
{
|
||||
provider.Enabled = true;
|
||||
await _providerService.UpdateAsync(provider);
|
||||
|
||||
if (IsProviderSubscriptionNowActive(parsedEvent, subscription))
|
||||
{
|
||||
// Update the CancelAtPeriodEnd subscription option to prevent the now active provider subscription from being cancelled
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAtPeriodEnd = false };
|
||||
await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StripeSubscriptionStatus.Active:
|
||||
{
|
||||
if (userId.HasValue)
|
||||
{
|
||||
await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await DisableSubscriberAsync(subscriberId, currentPeriodEnd);
|
||||
await SetSubscriptionToCancelAsync(subscription);
|
||||
}
|
||||
else if (SubscriptionBecameActive(parsedEvent, subscription))
|
||||
{
|
||||
await EnableSubscriberAsync(subscriberId, currentPeriodEnd);
|
||||
await RemovePendingCancellationAsync(subscription);
|
||||
}
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
|
||||
await subscriberId.Match(
|
||||
userId => _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd),
|
||||
async organizationId =>
|
||||
{
|
||||
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
|
||||
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
|
||||
|
||||
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
|
||||
{
|
||||
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
|
||||
}
|
||||
|
||||
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
|
||||
private static bool SubscriptionWentUnpaid(
|
||||
Event parsedEvent,
|
||||
Subscription currentSubscription) =>
|
||||
parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() is Subscription
|
||||
{
|
||||
Status:
|
||||
SubscriptionStatus.Trialing or
|
||||
SubscriptionStatus.Active or
|
||||
SubscriptionStatus.PastDue
|
||||
} && currentSubscription is
|
||||
{
|
||||
Status: SubscriptionStatus.Unpaid,
|
||||
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
|
||||
};
|
||||
|
||||
private static bool SubscriptionBecameActive(
|
||||
Event parsedEvent,
|
||||
Subscription currentSubscription) =>
|
||||
parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() is Subscription
|
||||
{
|
||||
Status:
|
||||
SubscriptionStatus.Incomplete or
|
||||
SubscriptionStatus.Unpaid
|
||||
} && currentSubscription is
|
||||
{
|
||||
Status: SubscriptionStatus.Active,
|
||||
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
|
||||
};
|
||||
|
||||
private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) =>
|
||||
subscriberId.Match(
|
||||
userId => _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd),
|
||||
async organizationId =>
|
||||
{
|
||||
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
if (organization != null)
|
||||
{
|
||||
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
|
||||
}
|
||||
},
|
||||
async providerId =>
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
if (provider != null)
|
||||
{
|
||||
provider.Enabled = false;
|
||||
await _providerService.UpdateAsync(provider);
|
||||
}
|
||||
});
|
||||
|
||||
private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) =>
|
||||
subscriberId.Match(
|
||||
userId => _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd),
|
||||
async organizationId =>
|
||||
{
|
||||
await _organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd);
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
if (organization != null)
|
||||
{
|
||||
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
|
||||
}
|
||||
},
|
||||
async providerId =>
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||
if (provider != null)
|
||||
{
|
||||
provider.Enabled = true;
|
||||
await _providerService.UpdateAsync(provider);
|
||||
}
|
||||
});
|
||||
|
||||
private async Task SetSubscriptionToCancelAsync(Subscription subscription)
|
||||
{
|
||||
if (subscription.TestClock != null)
|
||||
{
|
||||
await WaitForTestClockToAdvanceAsync(subscription.TestClock);
|
||||
}
|
||||
|
||||
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAt = now.AddDays(7),
|
||||
ProrationBehavior = ProrationBehavior.None,
|
||||
CancellationDetails = new SubscriptionCancellationDetailsOptions
|
||||
{
|
||||
Comment = $"Automation: Setting unpaid subscription to cancel 7 days from {now:yyyy-MM-dd}."
|
||||
}
|
||||
|
||||
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CancelSubscription(string subscriptionId) =>
|
||||
await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
||||
|
||||
private async Task VoidOpenInvoices(string subscriptionId)
|
||||
{
|
||||
var options = new InvoiceListOptions
|
||||
private async Task RemovePendingCancellationAsync(Subscription subscription)
|
||||
=> await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
Status = StripeInvoiceStatus.Open,
|
||||
Subscription = subscriptionId
|
||||
};
|
||||
var invoices = await _stripeFacade.ListInvoices(options);
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
await _stripeFacade.VoidInvoice(invoice.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsPremiumSubscriptionAsync(Subscription subscription)
|
||||
{
|
||||
var premiumPlans = await _pricingClient.ListPremiumPlans();
|
||||
var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet();
|
||||
return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provider subscription status has changed from a non-active to an active status type
|
||||
/// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false.
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent">The event containing the previous subscription status</param>
|
||||
/// <param name="subscription">The current subscription status</param>
|
||||
/// <returns>A boolean that represents whether the event status has changed from a non-active status to an active status</returns>
|
||||
private static bool IsProviderSubscriptionNowActive(Event parsedEvent, Subscription subscription)
|
||||
{
|
||||
if (parsedEvent.Data.PreviousAttributes == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var previousSubscription = parsedEvent
|
||||
.Data
|
||||
.PreviousAttributes
|
||||
.ToObject<Subscription>() as Subscription;
|
||||
|
||||
return previousSubscription?.Status switch
|
||||
{
|
||||
StripeSubscriptionStatus.IncompleteExpired
|
||||
or StripeSubscriptionStatus.Paused
|
||||
or StripeSubscriptionStatus.Incomplete
|
||||
or StripeSubscriptionStatus.Unpaid
|
||||
when subscription.Status == StripeSubscriptionStatus.Active => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
CancelAtPeriodEnd = false,
|
||||
ProrationBehavior = ProrationBehavior.None
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial.
|
||||
@@ -275,17 +231,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
.PreviousAttributes
|
||||
.ToObject<Subscription>() as Subscription;
|
||||
|
||||
// Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the
|
||||
// previous and/or current subscriptions.
|
||||
var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans())
|
||||
.Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null)
|
||||
.Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId)
|
||||
.ToHashSet();
|
||||
|
||||
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
|
||||
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
|
||||
// changed and unchanged.
|
||||
var previousSubscriptionHasSecretsManager =
|
||||
previousSubscription?.Items is not null &&
|
||||
previousSubscription.Items.Any(
|
||||
previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
|
||||
previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
|
||||
|
||||
var currentSubscriptionHasSecretsManager =
|
||||
subscription.Items.Any(
|
||||
currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
|
||||
currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
|
||||
|
||||
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
|
||||
{
|
||||
@@ -298,7 +261,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
?.Id == "sm-standalone";
|
||||
|
||||
var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id)
|
||||
.Contains(StripeConstants.CouponIDs.SecretsManagerStandalone);
|
||||
.Contains(CouponIDs.SecretsManagerStandalone);
|
||||
|
||||
if (customerHasSecretsManagerTrial)
|
||||
{
|
||||
@@ -311,75 +274,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
|
||||
var job = JobBuilder.Create<SubscriptionCancellationJob>()
|
||||
.WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations")
|
||||
.UsingJobData("subscriptionId", subscriptionId)
|
||||
.UsingJobData("organizationId", organizationId.ToString())
|
||||
.Build();
|
||||
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations")
|
||||
.StartAt(DateTimeOffset.UtcNow.AddDays(7))
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(job, trigger);
|
||||
}
|
||||
|
||||
private async Task HandleUnpaidProviderSubscriptionAsync(
|
||||
Guid providerId,
|
||||
Event parsedEvent,
|
||||
Subscription currentSubscription)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
if (provider == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
provider.Enabled = false;
|
||||
await _providerService.UpdateAsync(provider);
|
||||
|
||||
if (parsedEvent.Data.PreviousAttributes != null)
|
||||
{
|
||||
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
||||
|
||||
if (previousSubscription is
|
||||
{
|
||||
Status:
|
||||
StripeSubscriptionStatus.Trialing or
|
||||
StripeSubscriptionStatus.Active or
|
||||
StripeSubscriptionStatus.PastDue
|
||||
} && currentSubscription is
|
||||
{
|
||||
Status: StripeSubscriptionStatus.Unpaid,
|
||||
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
|
||||
})
|
||||
{
|
||||
if (currentSubscription.TestClock != null)
|
||||
{
|
||||
await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock);
|
||||
}
|
||||
|
||||
var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
|
||||
|
||||
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "An error occurred while trying to disable and schedule subscription cancellation for provider ({ProviderID})", providerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForTestClockToAdvanceAsync(TestClock testClock)
|
||||
{
|
||||
while (testClock.Status != "ready")
|
||||
|
||||
@@ -627,7 +627,7 @@ public class UpcomingInvoiceHandler(
|
||||
{
|
||||
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
|
||||
DiscountAmount = $"{coupon.PercentOff}%",
|
||||
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
public class MasterPasswordPolicyData : IPolicyDataModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum password complexity score (0-4). Null indicates no complexity requirement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minComplexity")]
|
||||
[Range(0, 4)]
|
||||
public int? MinComplexity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum password length (12-128). Null indicates no minimum length requirement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minLength")]
|
||||
[Range(12, 128)]
|
||||
public int? MinLength { get; set; }
|
||||
[JsonPropertyName("requireLower")]
|
||||
public bool? RequireLower { get; set; }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
public class PolicyStatus
|
||||
{
|
||||
public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null)
|
||||
{
|
||||
OrganizationId = policy?.OrganizationId ?? organizationId;
|
||||
Data = policy?.Data;
|
||||
Type = policy?.Type ?? policyType;
|
||||
Enabled = policy?.Enabled ?? false;
|
||||
}
|
||||
|
||||
public Guid OrganizationId { get; set; }
|
||||
public PolicyType Type { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string? Data { get; set; }
|
||||
|
||||
public T GetDataModel<T>() where T : IPolicyDataModel, new()
|
||||
{
|
||||
return CoreHelpers.LoadClassFromJsonData<T>(Data);
|
||||
}
|
||||
}
|
||||
@@ -778,7 +778,7 @@
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
|
||||
@@ -946,7 +946,7 @@
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||
|
||||
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IUserRepository userRepository,
|
||||
IMailService mailService,
|
||||
IEventService eventService,
|
||||
@@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo
|
||||
}
|
||||
|
||||
// Enterprise policy must be enabled
|
||||
var resetPasswordPolicy =
|
||||
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
|
||||
|
||||
public static class CollectionUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Arranges Collection and CollectionUser objects to create default user collections.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization ID.</param>
|
||||
/// <param name="organizationUserIds">The IDs for organization users who need default collections.</param>
|
||||
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
|
||||
/// <returns>A tuple containing the collections and collection users.</returns>
|
||||
public static (ICollection<Collection> collections, ICollection<CollectionUser> collectionUsers)
|
||||
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
|
||||
string defaultCollectionName)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var collectionUsers = new List<CollectionUser>();
|
||||
var collections = new List<Collection>();
|
||||
|
||||
foreach (var orgUserId in organizationUserIds)
|
||||
{
|
||||
var collectionId = CoreHelpers.GenerateComb();
|
||||
|
||||
collections.Add(new Collection
|
||||
{
|
||||
Id = collectionId,
|
||||
OrganizationId = organizationId,
|
||||
Name = defaultCollectionName,
|
||||
CreationDate = now,
|
||||
RevisionDate = now,
|
||||
Type = CollectionType.DefaultUserCollection,
|
||||
DefaultUserCollectionEmail = null
|
||||
|
||||
});
|
||||
|
||||
collectionUsers.Add(new CollectionUser
|
||||
{
|
||||
CollectionId = collectionId,
|
||||
OrganizationUserId = orgUserId,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true,
|
||||
});
|
||||
}
|
||||
|
||||
return (collections, collectionUsers);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,6 @@ public class VerifyOrganizationDomainCommand(
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
|
||||
|
||||
await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization));
|
||||
await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization, domain.DomainName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
return;
|
||||
}
|
||||
|
||||
await collectionRepository.CreateAsync(
|
||||
new Collection
|
||||
{
|
||||
OrganizationId = request.Organization!.Id,
|
||||
Name = request.DefaultUserCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
},
|
||||
groups: null,
|
||||
[new CollectionAccessSelection
|
||||
{
|
||||
Id = request.OrganizationUser!.Id,
|
||||
Manage = true
|
||||
}]);
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(
|
||||
request.Organization!.Id,
|
||||
[request.OrganizationUser!.Id],
|
||||
request.DefaultUserCollectionName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
@@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
|
||||
IUserService userService,
|
||||
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||
IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||
{
|
||||
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
|
||||
AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||
@@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
}
|
||||
|
||||
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
|
||||
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true }
|
||||
(await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled
|
||||
&& request.Organization is { UseAutomaticUserConfirmation: true };
|
||||
|
||||
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||
|
||||
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultCollection = new Collection
|
||||
{
|
||||
OrganizationId = organizationUser.OrganizationId,
|
||||
Name = defaultUserCollectionName,
|
||||
Type = CollectionType.DefaultUserCollection
|
||||
};
|
||||
var collectionUser = new CollectionAccessSelection
|
||||
{
|
||||
Id = organizationUser.Id,
|
||||
ReadOnly = false,
|
||||
HidePasswords = false,
|
||||
Manage = true
|
||||
};
|
||||
|
||||
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
|
||||
await _collectionRepository.CreateDefaultCollectionsAsync(
|
||||
organizationUser.OrganizationId,
|
||||
[organizationUser.Id],
|
||||
defaultUserCollectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
|
||||
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.Auth.Models.Business;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
|
||||
public class SendOrganizationInvitesCommand(
|
||||
IUserRepository userRepository,
|
||||
ISsoConfigRepository ssoConfigurationRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
|
||||
IMailService mailService) : ISendOrganizationInvitesCommand
|
||||
@@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
|
||||
// need to check the policy if the org has SSO enabled.
|
||||
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
|
||||
organization.UsePolicies &&
|
||||
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
|
||||
(await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled;
|
||||
|
||||
// Generate the list of org users and expiring tokens
|
||||
// create helper function to create expiring tokens
|
||||
|
||||
@@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">Revoked user to be restored.</param>
|
||||
/// <param name="restoringUserId">UserId of the user performing the action.</param>
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId);
|
||||
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
|
||||
@@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand
|
||||
/// <param name="userService">Passed in from caller to avoid circular dependency</param>
|
||||
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
|
||||
/// If an error occurs, the error message will be provided.</returns>
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand(
|
||||
IOrganizationService organizationService,
|
||||
IFeatureService featureService,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ICollectionRepository collectionRepository,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand
|
||||
{
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
|
||||
{
|
||||
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
|
||||
{
|
||||
@@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand(
|
||||
throw new BadRequestException("Only owners can restore other owners.");
|
||||
}
|
||||
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
@@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand(
|
||||
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
|
||||
{
|
||||
await RepositoryRestoreUserAsync(organizationUser);
|
||||
await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point.
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
|
||||
systemUser);
|
||||
|
||||
@@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
|
||||
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName)
|
||||
{
|
||||
if (organizationUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
@@ -93,7 +94,7 @@ public class RestoreOrganizationUserCommand(
|
||||
.twoFactorIsEnabled;
|
||||
}
|
||||
|
||||
if (organization.PlanType == PlanType.Free)
|
||||
if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue)
|
||||
{
|
||||
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
|
||||
}
|
||||
@@ -104,7 +105,16 @@ public class RestoreOrganizationUserCommand(
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
|
||||
organizationUser.Status = status;
|
||||
if (organizationUser.UserId.HasValue
|
||||
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId.Value)).State == OrganizationDataOwnershipState.Enabled
|
||||
&& status == OrganizationUserStatusType.Confirmed
|
||||
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)
|
||||
&& !string.IsNullOrWhiteSpace(defaultCollectionName))
|
||||
{
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
|
||||
[organizationUser.Id],
|
||||
defaultCollectionName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
|
||||
@@ -156,7 +166,8 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService)
|
||||
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService,
|
||||
string defaultCollectionName)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
@@ -224,12 +235,14 @@ public class RestoreOrganizationUserCommand(
|
||||
|
||||
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
|
||||
organizationUser.Status = status;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
@@ -238,9 +251,37 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore))
|
||||
{
|
||||
await CreateDefaultCollectionsForConfirmedUsersAsync(organizationId, defaultCollectionName,
|
||||
result.Where(r => r.Item2 == "").Select(x => x.Item1).ToList());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task CreateDefaultCollectionsForConfirmedUsersAsync(Guid organizationId, string defaultCollectionName,
|
||||
ICollection<OrganizationUser> restoredUsers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(defaultCollectionName))
|
||||
{
|
||||
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
|
||||
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId))
|
||||
.ToList();
|
||||
|
||||
var usersToCreateDefaultCollectionsFor = restoredUsers.Where(x =>
|
||||
organizationUsersDataOwnershipEnabled.Contains(x.Id)
|
||||
&& x.Status == OrganizationUserStatusType.Confirmed).ToList();
|
||||
|
||||
if (usersToCreateDefaultCollectionsFor.Count != 0)
|
||||
{
|
||||
await collectionRepository.CreateDefaultCollectionsAsync(organizationId,
|
||||
usersToCreateDefaultCollectionsFor.Select(x => x.Id),
|
||||
defaultCollectionName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
|
||||
{
|
||||
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
|
||||
public interface IPolicyQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a summary view of an organization's usage of a policy specified by the <paramref name="policyType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This query is the entrypoint for consumers interested in understanding how a particular <see cref="PolicyType"/>
|
||||
/// has been applied to an organization; the resultant <see cref="PolicyStatus"/> is not indicative of explicit
|
||||
/// policy configuration.
|
||||
/// </remarks>
|
||||
Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
|
||||
public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery
|
||||
{
|
||||
public async Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType)
|
||||
{
|
||||
var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType);
|
||||
return new PolicyStatus(organizationId, policyType, dbPolicy);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
|
||||
/// <param name="policyDetails">Collection of policy details that apply to this user id</param>
|
||||
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
|
||||
{
|
||||
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
|
||||
public bool CannotHaveEmergencyAccess() => policyDetails.Any();
|
||||
|
||||
public bool CannotJoinProvider() => policyDetails.Any();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
|
||||
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
|
||||
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
|
||||
services.AddScoped<IPolicyQuery, PolicyQuery>();
|
||||
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
|
||||
|
||||
services.AddPolicyValidators();
|
||||
|
||||
@@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator(
|
||||
var userOrgIds = requirements
|
||||
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
|
||||
.Where(request => request.ShouldCreateDefaultCollection)
|
||||
.Select(request => request.OrganizationUserId);
|
||||
.Select(request => request.OrganizationUserId)
|
||||
.ToList();
|
||||
|
||||
if (!userOrgIds.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await collectionRepository.UpsertDefaultCollectionsAsync(
|
||||
await collectionRepository.CreateDefaultCollectionsBulkAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
userOrgIds,
|
||||
defaultCollectionName);
|
||||
|
||||
@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
|
||||
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the organizations that have a verified domain matching the user's email domain.
|
||||
/// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
|
||||
/// This requires that the organization has claimed the user's domain and the user is an organization member.
|
||||
/// It excludes invited members.
|
||||
/// </summary>
|
||||
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
|
||||
|
||||
|
||||
@@ -28,21 +28,21 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the OrganizationUser</param>
|
||||
/// <returns>A tuple containing the OrganizationUser and its associated collections</returns>
|
||||
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
|
||||
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithSharedCollectionsAsync(Guid id);
|
||||
/// <summary>
|
||||
/// Returns the OrganizationUsers and their associated collections (excluding DefaultUserCollections).
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The id of the organization</param>
|
||||
/// <param name="includeGroups">Whether to include groups</param>
|
||||
/// <param name="includeCollections">Whether to include collections</param>
|
||||
/// <param name="includeSharedCollections">Whether to include shared collections</param>
|
||||
/// <returns>A list of OrganizationUserUserDetails</returns>
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeSharedCollections = false);
|
||||
/// <inheritdoc cref="GetManyDetailsByOrganizationAsync"/>
|
||||
/// <remarks>
|
||||
/// This method is optimized for performance.
|
||||
/// Reduces database round trips by fetching all data in fewer queries.
|
||||
/// </remarks>
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeSharedCollections = false);
|
||||
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||
OrganizationUserStatusType? status = null);
|
||||
Task<OrganizationUserOrganizationDetails?> GetDetailsByUserAsync(Guid userId, Guid organizationId,
|
||||
|
||||
@@ -27,7 +27,6 @@ public interface IOrganizationService
|
||||
OrganizationUserInvite invite, string externalId);
|
||||
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
|
||||
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
|
||||
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
|
||||
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
|
||||
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
|
||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||
|
||||
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IStripePaymentService _paymentService;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyQuery _policyQuery;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISsoUserRepository _ssoUserRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
@@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService
|
||||
IEventService eventService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IStripePaymentService paymentService,
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyQuery policyQuery,
|
||||
IPolicyService policyService,
|
||||
ISsoUserRepository ssoUserRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
@@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService
|
||||
_eventService = eventService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_paymentService = paymentService;
|
||||
_policyRepository = policyRepository;
|
||||
_policyQuery = policyQuery;
|
||||
_policyService = policyService;
|
||||
_ssoUserRepository = ssoUserRepository;
|
||||
_globalSettings = globalSettings;
|
||||
@@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService
|
||||
return (allOrgUsers, events);
|
||||
}
|
||||
|
||||
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>>();
|
||||
foreach (var orgUser in orgUsers)
|
||||
{
|
||||
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
result.Add(Tuple.Create(orgUser, "User invalid."));
|
||||
continue;
|
||||
}
|
||||
|
||||
await SendInviteAsync(orgUser, org, false);
|
||||
result.Add(Tuple.Create(orgUser, ""));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
|
||||
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
|
||||
|
||||
@@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
|
||||
// Make sure the organization has the policy enabled
|
||||
var resetPasswordPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword);
|
||||
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
|
||||
var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
|
||||
if (!resetPasswordPolicy.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization does not have the password reset policy enabled.");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
@@ -30,7 +31,8 @@ public static class PolicyDataValidator
|
||||
switch (policyType)
|
||||
{
|
||||
case PolicyType.MasterPassword:
|
||||
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
|
||||
var masterPasswordData = CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
|
||||
ValidateModel(masterPasswordData, policyType);
|
||||
break;
|
||||
case PolicyType.SendOptions:
|
||||
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
|
||||
@@ -44,11 +46,24 @@ public static class PolicyDataValidator
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : "";
|
||||
var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null;
|
||||
var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : "";
|
||||
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateModel(object model, PolicyType policyType)
|
||||
{
|
||||
var validationContext = new ValidationContext(model);
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
if (!Validator.TryValidateObject(model, validationContext, validationResults, true))
|
||||
{
|
||||
var errors = string.Join(", ", validationResults.Select(r => r.ErrorMessage));
|
||||
throw new BadRequestException($"Invalid data for {policyType} policy: {errors}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and deserializes policy metadata based on the policy type.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -14,8 +11,8 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public Guid GrantorId { get; set; }
|
||||
public Guid? GranteeId { get; set; }
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; }
|
||||
public string KeyEncrypted { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public short WaitTimeDays { 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