diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f42f226153..d7814849c6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "7.2.0", + "version": "7.3.2", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f598c0e1eb..41a148f115 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,9 @@ on: types: [opened, synchronize] workflow_call: inputs: {} + +permissions: + contents: read env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" @@ -19,7 +22,7 @@ env: jobs: lint: name: Lint - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -33,113 +36,15 @@ jobs: run: dotnet format --verify-no-changes build-artifacts: - name: Build artifacts - runs-on: ubuntu-22.04 + name: Build Docker images + runs-on: ubuntu-24.04 needs: - lint outputs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} - strategy: - fail-fast: false - matrix: - include: - - project_name: Admin - base_path: ./src - node: true - - project_name: Api - base_path: ./src - - project_name: Billing - base_path: ./src - - project_name: Events - base_path: ./src - - project_name: EventsProcessor - base_path: ./src - - project_name: Icons - base_path: ./src - - project_name: Identity - base_path: ./src - - project_name: MsSqlMigratorUtility - base_path: ./util - dotnet: true - - project_name: Notifications - base_path: ./src - - project_name: Scim - base_path: ./bitwarden_license/src - dotnet: true - - project_name: Server - base_path: ./util - - project_name: Setup - base_path: ./util - - project_name: Sso - base_path: ./bitwarden_license/src - node: true - steps: - - name: Check secrets - id: check-secrets - run: | - has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT - - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: "npm" - cache-dependency-path: "**/package-lock.json" - node-version: "16" - - - name: Print environment - run: | - whoami - dotnet --info - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Build node - if: ${{ matrix.node }} - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - npm ci - npm run build - - - name: Publish project - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - echo "Publish" - dotnet publish -c "Release" -o obj/build-output/publish - - cd obj/build-output/publish - zip -r ${{ matrix.project_name }}.zip . - mv ${{ matrix.project_name }}.zip ../../../ - - pwd - ls -atlh ../../../ - - - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ matrix.project_name }}.zip - path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip - if-no-files-found: error - - build-docker: - name: Build Docker images - runs-on: ubuntu-22.04 permissions: security-events: write id-token: write - needs: - - build-artifacts - if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }} strategy: fail-fast: false matrix: @@ -147,6 +52,7 @@ jobs: - project_name: Admin base_path: ./src dotnet: true + node: true - project_name: Api base_path: ./src dotnet: true @@ -180,9 +86,6 @@ jobs: - project_name: Scim base_path: ./bitwarden_license/src dotnet: true - - project_name: Server - base_path: ./util - dotnet: true - project_name: Setup base_path: ./util dotnet: true @@ -190,6 +93,14 @@ jobs: base_path: ./bitwarden_license/src dotnet: true steps: + - name: Check secrets + id: check-secrets + env: + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + run: | + has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -201,13 +112,67 @@ jobs: id: publish-branch-check run: | IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES - if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then echo "is_publish_branch=true" >> $GITHUB_ENV else echo "is_publish_branch=false" >> $GITHUB_ENV fi + - name: Set up .NET + uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + + - name: Set up Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + cache: "npm" + cache-dependency-path: "**/package-lock.json" + node-version: "16" + + - name: Print environment + run: | + whoami + dotnet --info + node --version + npm --version + echo "GitHub ref: $GITHUB_REF" + echo "GitHub event: $GITHUB_EVENT" + + - name: Build node + if: ${{ matrix.node }} + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + run: | + npm ci + npm run build + + - name: Publish project + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + if: ${{ matrix.dotnet }} + run: | + echo "Publish" + dotnet publish -c "Release" -o obj/build-output/publish + + cd obj/build-output/publish + zip -r ${{ matrix.project_name }}.zip . + mv ${{ matrix.project_name }}.zip ../../../ + + pwd + ls -atlh ../../../ + + - name: Upload project artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + if: ${{ matrix.dotnet }} + with: + name: ${{ matrix.project_name }}.zip + path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip + if-no-files-found: error + + ########## Set up Docker ########## + - name: Set up QEMU emulators + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + ########## ACRs ########## - name: Azure Login id: azure-login @@ -273,26 +238,16 @@ jobs: fi echo "tags=$TAGS" >> $GITHUB_OUTPUT - - name: Get build artifact - if: ${{ matrix.dotnet }} - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - with: - name: ${{ matrix.project_name }}.zip - - - name: Set up build artifact - if: ${{ matrix.dotnet }} - run: | - mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish - unzip ${{ matrix.project_name }}.zip \ - -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish - - name: Build Docker image - id: build-docker + id: build-artifacts uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: - context: ${{ matrix.base_path }}/${{ matrix.project_name }} + context: . file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile - platforms: linux/amd64 + platforms: | + linux/amd64, + linux/arm/v7, + linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} secrets: | @@ -305,7 +260,7 @@ jobs: - name: Sign image with Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' env: - DIGEST: ${{ steps.build-docker.outputs.digest }} + DIGEST: ${{ steps.build-artifacts.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} run: | IFS="," read -a tags <<< "${TAGS}" @@ -335,10 +290,9 @@ jobs: upload: name: Upload - runs-on: ubuntu-22.04 - needs: build-docker + runs-on: ubuntu-24.04 + needs: build-artifacts permissions: - contents: read id-token: write actions: read steps: @@ -383,9 +337,9 @@ jobs: # Run setup docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT @@ -406,14 +360,6 @@ jobs: - name: Azure Logout uses: bitwarden/gh-actions/azure-logout@main - - name: Make Docker stub checksums - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - run: | - sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt - sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt - - name: Upload Docker stub US artifact if: | github.event_name != 'pull_request' @@ -434,26 +380,6 @@ jobs: path: docker-stub-EU.zip if-no-files-found: error - - name: Upload Docker stub US checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-US-sha256.txt - path: docker-stub-US-sha256.txt - if-no-files-found: error - - - name: Upload Docker stub EU checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-EU-sha256.txt - path: docker-stub-EU-sha256.txt - if-no-files-found: error - - name: Build Public API Swagger run: | cd ./src/Api @@ -521,7 +447,7 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - lint defaults: @@ -577,9 +503,9 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - - build-docker + - build-artifacts permissions: id-token: write steps: @@ -621,7 +547,7 @@ jobs: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - - build-docker + - build-artifacts permissions: id-token: write steps: @@ -663,7 +589,6 @@ jobs: name: Setup Ephemeral Environment needs: - build-artifacts - - build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' @@ -671,8 +596,9 @@ jobs: uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main with: project: server - pull_request_number: ${{ github.event.number }} + pull_request_number: ${{ github.event.number || 0 }} secrets: inherit + permissions: read-all check-failures: name: Check for failures @@ -681,7 +607,6 @@ jobs: needs: - lint - build-artifacts - - build-docker - upload - build-mssqlmigratorutility - self-host-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f749d2e4f0..1a9cc2d966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ on: env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" +permissions: + contents: read + jobs: setup: name: Setup @@ -65,9 +68,7 @@ jobs: workflow_conclusion: success branch: ${{ needs.setup.outputs.branch-name }} artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Dry Run - Download latest release Docker stubs @@ -78,9 +79,7 @@ jobs: workflow_conclusion: success branch: main artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Create release @@ -88,9 +87,7 @@ jobs: uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 with: artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" commit: ${{ github.sha }} tag: "v${{ needs.setup.outputs.release_version }}" diff --git a/Directory.Build.props b/Directory.Build.props index f403c0f692..a798c7f335 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.5.2 + 2025.6.1 Bit.$(MSBuildProjectName) enable @@ -69,5 +69,4 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 73992785d7..c817931c67 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ Github Workflow build on main - - DockerHub - gitter chat @@ -26,12 +23,12 @@ Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/gett ## Deploy

- + docker

-You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [Docker Hub](https://hub.docker.com/u/bitwarden/). +You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [GitHub Container Registry](https://github.com/orgs/bitwarden/packages). Full documentation for deploying Bitwarden with Docker can be found in our help center at: https://help.bitwarden.com/article/install-on-premise/ diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index ad2d2d2aa1..3c75be756a 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -287,11 +287,10 @@ public class ProviderService : IProviderService foreach (var user in users) { - if (!keyedFilteredUsers.ContainsKey(user.Id)) + if (!keyedFilteredUsers.TryGetValue(user.Id, out var providerUser)) { continue; } - var providerUser = keyedFilteredUsers[user.Id]; try { if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 8f6eb07fe1..8e8a89ae58 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -1,6 +1,5 @@ #nullable enable using System.Diagnostics.CodeAnalysis; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -27,7 +26,6 @@ using Stripe; namespace Bit.Commercial.Core.Billing.Providers.Services; -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 8c90d778bc..2b337fb4bb 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -550,6 +550,15 @@ public class ProviderBillingService( [ new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + options.TaxIdData.Add(new CustomerTaxIdDataOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{taxInfo.TaxIdNumber}" + }); + } } if (!string.IsNullOrEmpty(provider.DiscountId)) diff --git a/bitwarden_license/src/Scim/.dockerignore b/bitwarden_license/src/Scim/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/bitwarden_license/src/Scim/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/bitwarden_license/src/Scim/Dockerfile b/bitwarden_license/src/Scim/Dockerfile index 6970dfa7bb..a0c5c88e49 100644 --- a/bitwarden_license/src/Scim/Dockerfile +++ b/bitwarden_license/src/Scim/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Scim +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,11 +53,10 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/bitwarden_license/src/Scim/out /app +COPY ./bitwarden_license/src/Scim/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Scim/Program.cs b/bitwarden_license/src/Scim/Program.cs index 5d7d505aac..92f12f59dd 100644 --- a/bitwarden_license/src/Scim/Program.cs +++ b/bitwarden_license/src/Scim/Program.cs @@ -16,8 +16,8 @@ public class Program { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Scim/entrypoint.sh b/bitwarden_license/src/Scim/entrypoint.sh index edc3bbe14a..41930504d3 100644 --- a/bitwarden_license/src/Scim/entrypoint.sh +++ b/bitwarden_license/src/Scim/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll +if [[ $globalSettings__selfHosted == "true" ]]; then + if [[ -z $globalSettings__identityServer__certificateLocation ]]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi +fi + +exec $gosu_cmd /app/Scim diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index f41d2d3c65..12394ff598 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -370,8 +370,8 @@ public class AccountController : Controller // for the user identifier. static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier && (c.Properties == null - || !c.Properties.ContainsKey(SamlPropertyKeys.ClaimFormat) - || c.Properties[SamlPropertyKeys.ClaimFormat] != SamlNameIdFormats.Transient); + || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) + || claimFormat != SamlNameIdFormats.Transient); // Try to determine the unique id of the external user (issued by the provider) // the most common claim type for that are the sub claim and the NameIdentifier @@ -499,9 +499,9 @@ public class AccountController : Controller // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one if (orgUser == null && organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var initialSeatCount = organization.Seats.Value; - var availableSeats = initialSeatCount - occupiedSeats; + var availableSeats = initialSeatCount - occupiedSeats.Total; if (availableSeats < 1) { try diff --git a/bitwarden_license/src/Sso/Dockerfile b/bitwarden_license/src/Sso/Dockerfile index 6970dfa7bb..d5d012b416 100644 --- a/bitwarden_license/src/Sso/Dockerfile +++ b/bitwarden_license/src/Sso/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Sso +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,11 +53,10 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/bitwarden_license/src/Sso/out /app +COPY ./bitwarden_license/src/Sso/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Sso/Program.cs b/bitwarden_license/src/Sso/Program.cs index 051caca9c2..1a8ce6eb88 100644 --- a/bitwarden_license/src/Sso/Program.cs +++ b/bitwarden_license/src/Sso/Program.cs @@ -17,8 +17,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs index 9221877a04..825ed74dc8 100644 --- a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs @@ -46,9 +46,9 @@ public static class OpenIdConnectOptionsExtensions // Handle State if we've gotten that back var decodedState = options.StateDataFormat.Unprotect(state); - if (decodedState != null && decodedState.Items.ContainsKey("scheme")) + if (decodedState != null && decodedState.Items.TryGetValue("scheme", out var stateScheme)) { - return decodedState.Items["scheme"] == scheme; + return stateScheme == scheme; } } catch diff --git a/bitwarden_license/src/Sso/entrypoint.sh b/bitwarden_license/src/Sso/entrypoint.sh index 2c7bd18b84..c762659fb3 100644 --- a/bitwarden_license/src/Sso/entrypoint.sh +++ b/bitwarden_license/src/Sso/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,37 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -mkdir -p /etc/bitwarden/identity -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/identity/identity.pfx /app/identity.pfx -fi + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi -chown -R $USERNAME:$GROUPNAME /app - -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll +if [[ $globalSettings__selfHosted == "true" ]]; then + if [[ -z $globalSettings__identityServer__certificateLocation ]]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi +fi + +exec $gosu_cmd /app/Sso diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 601989a473..0ee4aa53a9 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -99,7 +99,7 @@ services: - idp rabbitmq: - image: rabbitmq:management + image: rabbitmq:4.1.0-management container_name: rabbitmq ports: - "5672:5672" @@ -108,7 +108,7 @@ services: RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} volumes: - - rabbitmq_data:/var/lib/rabbitmq_data + - rabbitmq_data:/var/lib/rabbitmq profiles: - rabbitmq diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index 073a44618f..b107bc6190 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -33,6 +33,39 @@ "Name": "events-webhook-subscription" } ] + }, + { + "Name": "event-integrations", + "Subscriptions": [ + { + "Name": "integration-slack-subscription", + "Rules": [ + { + "Name": "slack-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "slack" + } + } + } + ] + }, + { + "Name": "integration-webhook-subscription", + "Rules": [ + { + "Name": "webhook-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "webhook" + } + } + } + ] + } + ] } ] } diff --git a/dev/setup_azurite.ps1 b/dev/setup_azurite.ps1 index ad9808f6c3..03b92d4465 100755 --- a/dev/setup_azurite.ps1 +++ b/dev/setup_azurite.ps1 @@ -11,7 +11,7 @@ $corsRules = (@{ AllowedMethods = @("Get", "PUT"); }); $containers = "attachments", "sendfiles", "misc"; -$queues = "event", "notifications", "reference-events", "mail"; +$queues = "event", "notifications", "mail"; $tables = "event", "metadata", "installationdevice"; # End configuration diff --git a/global.json b/global.json index d04c13bbb5..d25197db39 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ }, "msbuild-sdks": { "Microsoft.Build.Traversal": "4.1.0", - "Microsoft.Build.Sql": "0.1.9-preview" + "Microsoft.Build.Sql": "1.0.0" } } diff --git a/src/Admin/.dockerignore b/src/Admin/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Admin/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 8cd2222dbf..ecdd372df4 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -12,7 +12,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -20,9 +19,6 @@ using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; @@ -45,12 +41,9 @@ public class OrganizationsController : Controller private readonly IPaymentService _paymentService; private readonly IApplicationCacheService _applicationCacheService; private readonly GlobalSettings _globalSettings; - private readonly IReferenceEventService _referenceEventService; - private readonly IUserService _userService; private readonly IProviderRepository _providerRepository; private readonly ILogger _logger; private readonly IAccessControlService _accessControlService; - private readonly ICurrentContext _currentContext; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -73,12 +66,9 @@ public class OrganizationsController : Controller IPaymentService paymentService, IApplicationCacheService applicationCacheService, GlobalSettings globalSettings, - IReferenceEventService referenceEventService, - IUserService userService, IProviderRepository providerRepository, ILogger logger, IAccessControlService accessControlService, - ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, IServiceAccountRepository serviceAccountRepository, @@ -100,12 +90,9 @@ public class OrganizationsController : Controller _paymentService = paymentService; _applicationCacheService = applicationCacheService; _globalSettings = globalSettings; - _referenceEventService = referenceEventService; - _userService = userService; _providerRepository = providerRepository; _logger = logger; _accessControlService = accessControlService; - _currentContext = currentContext; _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; @@ -255,10 +242,32 @@ public class OrganizationsController : Controller Seats = organization.Seats }; + if (model.PlanType.HasValue) + { + var freePlan = await _pricingClient.GetPlanOrThrow(model.PlanType.Value); + var isDowngradingToFree = organization.PlanType != PlanType.Free && model.PlanType.Value == PlanType.Free; + if (isDowngradingToFree) + { + if (model.Seats.HasValue && model.Seats.Value > freePlan.PasswordManager.MaxSeats) + { + TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxSeats} seats cannot be downgraded to the Free plan"; + return RedirectToAction("Edit", new { id }); + } + + if (model.MaxCollections > freePlan.PasswordManager.MaxCollections) + { + TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxCollections} collections cannot be downgraded to the Free plan. Your organization currently has {organization.MaxCollections} collections."; + return RedirectToAction("Edit", new { id }); + } + + model.MaxStorageGb = null; + model.ExpirationDate = null; + model.Enabled = true; + } + } + UpdateOrganization(organization, model); - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); - if (organization.UseSecretsManager && !plan.SupportsSecretsManager) { TempData["Error"] = "Plan does not support Secrets Manager"; @@ -272,11 +281,6 @@ public class OrganizationsController : Controller await _organizationRepository.ReplaceAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) - { - EventRaisedByUser = _userService.GetUserName(User), - SalesAssistedTrialStarted = model.SalesAssistedTrialStarted, - }); return RedirectToAction("Edit", new { id }); } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 69486bdcd2..412b17b3d7 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -44,6 +44,8 @@ public class OrganizationViewModel orgUsers .Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus) .Select(u => u.Email)); + OwnersDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Owner && u.Status == organizationUserStatus); + AdminsDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus); SecretsCount = secretsCount; ProjectsCount = projectCount; ServiceAccountsCount = serviceAccountsCount; @@ -70,4 +72,6 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public IEnumerable OwnersDetails { get; set; } + public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index f240cb192f..690ee3d778 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,13 +1,9 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models -@using Bit.Core @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions -@using Bit.Core.Services -@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService -@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -19,12 +15,10 @@ var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); - var canConvertToBusinessUnit = - FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && - AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && - Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && - !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && - Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; + var canConvertToBusinessUnit = AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && + Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && + !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; } @section Scripts { diff --git a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml index a0d421235d..9b2f7d69f8 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml @@ -19,12 +19,6 @@ @Model.UserConfirmedCount) -
Owners
-
@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)
- -
Admins
-
@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)
-
Using 2FA
@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")
@@ -76,3 +70,49 @@
Secrets Manager Seats
@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )
+ +

Administrators

+
+
+
+ + + + + + + + + + @if(!Model.Admins.Any() && !Model.Owners.Any()) + { + + + + } + else + { + @foreach(var owner in Model.OwnersDetails) + { + + + + + + } + + @foreach(var admin in Model.AdminsDetails) + { + + + + + + + } + } + +
EmailRoleStatus
No results to list.
@owner.EmailOwner@owner.Status
@admin.EmailAdmin@admin.Status
+
+
+
diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs index be3a94949f..9275f41932 100644 --- a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -2,7 +2,6 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -18,7 +17,6 @@ namespace Bit.Admin.Billing.Controllers; [Authorize] [Route("organizations/billing/{organizationId:guid}/business-unit")] -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConversionController( IBusinessUnitConverter businessUnitConverter, IOrganizationRepository organizationRepository, diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 79d117681c..d6b42eadfb 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,21 +1,71 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Set up Node +ARG NODE_VERSION=20 +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm@latest && \ + rm -rf /var/lib/apt/lists/* + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Admin +RUN npm ci +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN npm run build +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ gosu \ curl \ - krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/src/Admin/out /app +COPY ./src/Admin/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - -HEALTHCHECK CMD curl -f http://localhost:5000 || exit 1 +HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs index 15b8d894b7..89f04230b3 100644 --- a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs @@ -39,7 +39,7 @@ public class ReadOnlyEnvIdentityUserStore : ReadOnlyIdentityUserStore } } - var userStamp = usersDict.ContainsKey(normalizedEmail) ? usersDict[normalizedEmail] : null; + var userStamp = usersDict.GetValueOrDefault(normalizedEmail); if (userStamp == null) { return Task.FromResult(null); diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index fb5dc7e08b..05bf35d41d 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -20,8 +20,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Admin/Services/AccessControlService.cs b/src/Admin/Services/AccessControlService.cs index f45f30e216..a2ba9fa6ff 100644 --- a/src/Admin/Services/AccessControlService.cs +++ b/src/Admin/Services/AccessControlService.cs @@ -29,12 +29,12 @@ public class AccessControlService : IAccessControlService } var userRole = GetUserRoleFromClaim(); - if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.ContainsKey(userRole)) + if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.TryGetValue(userRole, out var rolePermissions)) { return false; } - return RolePermissionMapping.RolePermissions[userRole].Contains(permission); + return rolePermissions.Contains(permission); } public string GetUserRole(string userEmail) diff --git a/src/Admin/entrypoint.sh b/src/Admin/entrypoint.sh index 2c564b1ce6..4d7d238d25 100644 --- a/src/Admin/entrypoint.sh +++ b/src/Admin/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Admin.dll +exec $gosu_cmd /app/Admin diff --git a/src/Api/.dockerignore b/src/Api/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Api/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index c490e90150..11af4d5e0a 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 357db5ad1e..8d7df4160d 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -25,7 +25,7 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Authenticator)) + else { providers.Remove(TwoFactorProviderType.Authenticator); } @@ -62,7 +62,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.Duo)) + else { providers.Remove(TwoFactorProviderType.Duo); } @@ -88,7 +88,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo)) + else { providers.Remove(TwoFactorProviderType.OrganizationDuo); } @@ -145,7 +145,7 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.YubiKey)) + else { providers.Remove(TwoFactorProviderType.YubiKey); } @@ -228,7 +228,7 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Email)) + else { providers.Remove(TwoFactorProviderType.Email); } diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs index f791c6fb1e..71569174a7 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs @@ -13,9 +13,9 @@ public class TwoFactorAuthenticatorResponseModel : ResponseModel ArgumentNullException.ThrowIfNull(user); var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); - if (provider?.MetaData?.ContainsKey("Key") ?? false) + if (provider?.MetaData?.TryGetValue("Key", out var keyValue) ?? false) { - Key = (string)provider.MetaData["Key"]; + Key = (string)keyValue; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index ee1797f83e..d1d87d85b5 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -15,9 +15,9 @@ public class TwoFactorEmailResponseModel : ResponseModel } var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (provider?.MetaData?.ContainsKey("Email") ?? false) + if (provider?.MetaData?.TryGetValue("Email", out var email) ?? false) { - Email = (string)provider.MetaData["Email"]; + Email = (string)email; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 014863497d..0a97367017 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -19,29 +19,29 @@ public class TwoFactorYubiKeyResponseModel : ResponseModel { Enabled = provider.Enabled; - if (provider.MetaData.ContainsKey("Key1")) + if (provider.MetaData.TryGetValue("Key1", out var key1)) { - Key1 = (string)provider.MetaData["Key1"]; + Key1 = (string)key1; } - if (provider.MetaData.ContainsKey("Key2")) + if (provider.MetaData.TryGetValue("Key2", out var key2)) { - Key2 = (string)provider.MetaData["Key2"]; + Key2 = (string)key2; } - if (provider.MetaData.ContainsKey("Key3")) + if (provider.MetaData.TryGetValue("Key3", out var key3)) { - Key3 = (string)provider.MetaData["Key3"]; + Key3 = (string)key3; } - if (provider.MetaData.ContainsKey("Key4")) + if (provider.MetaData.TryGetValue("Key4", out var key4)) { - Key4 = (string)provider.MetaData["Key4"]; + Key4 = (string)key4; } - if (provider.MetaData.ContainsKey("Key5")) + if (provider.MetaData.TryGetValue("Key5", out var key5)) { - Key5 = (string)provider.MetaData["Key5"]; + Key5 = (string)key5; } - if (provider.MetaData.ContainsKey("Nfc")) + if (provider.MetaData.TryGetValue("Nfc", out var nfc)) { - Nfc = (bool)provider.MetaData["Nfc"]; + Nfc = (bool)nfc; } } else diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 49ff679bb8..10d386641d 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -6,14 +6,10 @@ using Bit.Api.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -161,8 +157,6 @@ public class AccountsController( [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, - [FromServices] ICurrentContext currentContext, - [FromServices] IReferenceEventService referenceEventService, [FromServices] ISubscriberService subscriberService) { var user = await userService.GetUserByPrincipalAsync(User); @@ -175,12 +169,6 @@ public class AccountsController( await subscriberService.CancelSubscription(user, new OffboardingSurveyResponse { UserId = user.Id, Reason = request.Reason, Feedback = request.Feedback }, user.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - user, - currentContext) - { EndOfPeriod = user.IsExpired() }); } [HttpPost("reinstate-premium")] diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 094ca0a435..f1ab1be6bd 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -4,7 +4,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Queries.Organizations; -using Bit.Core; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; @@ -25,7 +25,6 @@ namespace Bit.Api.Billing.Controllers; public class OrganizationBillingController( IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, - IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IOrganizationWarningsQuery organizationWarningsQuery, @@ -282,17 +281,36 @@ public class OrganizationBillingController( } var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) { return Error.NotFound(); } + var existingPlan = organization.PlanType; var organizationSignup = model.ToOrganizationSignup(user); var sale = OrganizationSale.From(organization, organizationSignup); var plan = await pricingClient.GetPlanOrThrow(model.PlanType); sale.Organization.PlanType = plan.Type; sale.Organization.Plan = plan.Name; sale.SubscriptionSetup.SkipTrial = true; + if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) + { + sale.Organization.UseTotp = plan.HasTotp; + sale.Organization.UseGroups = plan.HasGroups; + sale.Organization.UseDirectory = plan.HasDirectory; + sale.Organization.SelfHost = plan.HasSelfHost; + sale.Organization.UsersGetPremium = plan.UsersGetPremium; + sale.Organization.UseEvents = plan.HasEvents; + sale.Organization.Use2fa = plan.Has2fa; + sale.Organization.UseApi = plan.HasApi; + sale.Organization.UsePolicies = plan.HasPolicies; + sale.Organization.UseSso = plan.HasSso; + sale.Organization.UseResetPassword = plan.HasResetPassword; + sale.Organization.UseKeyConnector = plan.HasKeyConnector; + sale.Organization.UseScim = plan.HasScim; + sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; + sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; + sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; + } if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) { @@ -318,14 +336,6 @@ public class OrganizationBillingController( [FromRoute] Guid organizationId, [FromBody] SetupBusinessUnitRequestBody requestBody) { - var enableOrganizationBusinessUnitConversion = - featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); - - if (!enableOrganizationBusinessUnitConversion) - { - return Error.NotFound(); - } - var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index bd5ab8cef4..c8a3c20c91 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -20,9 +20,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -44,7 +41,6 @@ public class OrganizationsController( IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, - IReferenceEventService referenceEventService, ISubscriberService subscriberService, IOrganizationInstallationRepository organizationInstallationRepository, IPricingClient pricingClient) @@ -246,14 +242,6 @@ public class OrganizationsController( Feedback = request.Feedback }, organization.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - organization, - currentContext) - { - EndOfPeriod = organization.IsExpired() - }); } [HttpPost("{id:guid}/reinstate")] diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 37130d54ce..1309b2df6d 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -81,13 +81,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] UpdatePaymentMethodRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) @@ -111,13 +104,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] VerifyBankAccountRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs index 1dfc79be21..341dbceadf 100644 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -12,7 +12,8 @@ public record OrganizationMetadataResponse( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadataResponse From(OrganizationMetadata metadata) => new( @@ -25,5 +26,6 @@ public record OrganizationMetadataResponse( metadata.IsSubscriptionCanceled, metadata.InvoiceDueDate, metadata.InvoiceCreatedDate, - metadata.SubPeriodEndDate); + metadata.SubPeriodEndDate, + metadata.OrganizationOccupiedSeats); } diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index f12027cb31..e0ec40d0ab 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -8,7 +8,7 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Tools.Controllers; +namespace Bit.Api.Dirt.Controllers; [Route("hibp")] [Authorize("Application")] diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index 4c0a802da2..2f7a5a4328 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -1,16 +1,16 @@ -using Bit.Api.Tools.Models; -using Bit.Api.Tools.Models.Response; +using Bit.Api.Dirt.Models; +using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; +using Bit.Core.Dirt.Reports.Entities; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Tools.Controllers; +namespace Bit.Api.Dirt.Controllers; [Route("reports")] [Authorize("Application")] @@ -47,7 +47,7 @@ public class ReportsController : Controller [HttpGet("member-cipher-details/{orgId}")] public async Task> GetMemberCipherDetails(Guid orgId) { - // Using the AccessReports permission here until new permissions + // Using the AccessReports permission here until new permissions // are needed for more control over reports if (!await _currentContext.AccessReports(orgId)) { @@ -84,7 +84,7 @@ public class ReportsController : Controller } /// - /// Contains the organization member info, the cipher ids associated with the member, + /// Contains the organization member info, the cipher ids associated with the member, /// and details on their collections, groups, and permissions /// /// Request to the MemberAccessCipherDetailsQuery diff --git a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs index 93467e1175..5dbc07afb5 100644 --- a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs +++ b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.Tools.Models; +namespace Bit.Api.Dirt.Models; public class PasswordHealthReportApplicationModel { diff --git a/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs index b110c316c1..b8356e5d44 100644 --- a/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs +++ b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs @@ -1,10 +1,10 @@ -using Bit.Core.Tools.Models.Data; +using Bit.Core.Dirt.Reports.Models.Data; -namespace Bit.Api.Tools.Models.Response; +namespace Bit.Api.Dirt.Models.Response; /// /// Contains the collections and group collections a user has access to including -/// the permission level for the collection and group collection. +/// the permission level for the collection and group collection. /// public class MemberAccessReportResponseModel { diff --git a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs index d927da8123..30065ad05a 100644 --- a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs +++ b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs @@ -1,6 +1,6 @@ -using Bit.Core.Tools.Models.Data; +using Bit.Core.Dirt.Reports.Models.Data; -namespace Bit.Api.Tools.Models.Response; +namespace Bit.Api.Dirt.Models.Response; public class MemberCipherDetailsResponseModel { diff --git a/src/Api/Dockerfile b/src/Api/Dockerfile index 6970dfa7bb..29adde878c 100644 --- a/src/Api/Dockerfile +++ b/src/Api/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Api +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,13 +53,11 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/src/Api/out /app +COPY ./src/Api/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 58968d4be7..d08db64290 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; namespace Bit.Api.Models.Public.Response; @@ -20,6 +21,7 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel Id = collection.Id; ExternalId = collection.ExternalId; Groups = groups?.Select(c => new AssociationWithPermissionsResponseModel(c)); + Type = collection.Type; } /// @@ -38,4 +40,8 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel /// The associated groups that this collection is assigned to. /// public IEnumerable Groups { get; set; } + /// + /// The type of this collection + /// + public CollectionType Type { get; set; } } diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index d56ef5469a..5ce8310117 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -18,12 +19,14 @@ public class CollectionResponseModel : ResponseModel OrganizationId = collection.OrganizationId; Name = collection.Name; ExternalId = collection.ExternalId; + Type = collection.Type; } public Guid Id { get; set; } public Guid OrganizationId { get; set; } public string Name { get; set; } public string ExternalId { get; set; } + public CollectionType Type { get; set; } } /// diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index 9997e7502c..dd653bb873 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -5,7 +5,6 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Identity; -using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -16,9 +15,6 @@ using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -30,7 +26,6 @@ public class SecretsController : Controller private readonly ICurrentContext _currentContext; private readonly IProjectRepository _projectRepository; private readonly ISecretRepository _secretRepository; - private readonly IOrganizationRepository _organizationRepository; private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; @@ -39,14 +34,12 @@ public class SecretsController : Controller private readonly ISecretAccessPoliciesUpdatesQuery _secretAccessPoliciesUpdatesQuery; private readonly IUserService _userService; private readonly IEventService _eventService; - private readonly IReferenceEventService _referenceEventService; private readonly IAuthorizationService _authorizationService; public SecretsController( ICurrentContext currentContext, IProjectRepository projectRepository, ISecretRepository secretRepository, - IOrganizationRepository organizationRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, @@ -55,13 +48,11 @@ public class SecretsController : Controller ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery, IUserService userService, IEventService eventService, - IReferenceEventService referenceEventService, IAuthorizationService authorizationService) { _currentContext = currentContext; _projectRepository = projectRepository; _secretRepository = secretRepository; - _organizationRepository = organizationRepository; _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; @@ -70,7 +61,6 @@ public class SecretsController : Controller _secretAccessPoliciesUpdatesQuery = secretAccessPoliciesUpdatesQuery; _userService = userService; _eventService = eventService; - _referenceEventService = referenceEventService; _authorizationService = authorizationService; } @@ -148,9 +138,6 @@ public class SecretsController : Controller if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) { await _eventService.LogServiceAccountSecretEventAsync(userId, secret, EventType.Secret_Retrieved); - - var org = await _organizationRepository.GetByIdAsync(secret.OrganizationId); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.SmServiceAccountAccessedSecret, org, _currentContext)); } return new SecretResponseModel(secret, access.Read, access.Write); @@ -266,7 +253,7 @@ public class SecretsController : Controller throw new NotFoundException(); } - await LogSecretsRetrievalAsync(secrets.First().OrganizationId, secrets); + await LogSecretsRetrievalAsync(secrets); var responses = secrets.Select(s => new BaseSecretResponseModel(s)); return new ListResponseModel(responses); @@ -303,21 +290,18 @@ public class SecretsController : Controller if (syncResult.HasChanges) { - await LogSecretsRetrievalAsync(organizationId, syncResult.Secrets); + await LogSecretsRetrievalAsync(syncResult.Secrets); } return new SecretsSyncResponseModel(syncResult.HasChanges, syncResult.Secrets); } - private async Task LogSecretsRetrievalAsync(Guid organizationId, IEnumerable secrets) + private async Task LogSecretsRetrievalAsync(IEnumerable secrets) { if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) { var userId = _userService.GetProperUserId(User)!.Value; - var org = await _organizationRepository.GetByIdAsync(organizationId); await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, EventType.Secret_Retrieved); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.SmServiceAccountAccessedSecret, org, _currentContext)); } } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index e24f96a7a9..c2a75c9278 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -31,8 +31,8 @@ using Bit.Api.Billing; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Tools.ImportFeatures; -using Bit.Core.Tools.ReportFeatures; using Bit.Core.Auth.Models.Api.Request; +using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; #if !OSS diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index b18e603c0f..a51ec942cf 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -5,7 +5,6 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -33,7 +32,6 @@ public class SendsController : Controller private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; public SendsController( ISendRepository sendRepository, @@ -43,8 +41,7 @@ public class SendsController : Controller INonAnonymousSendCommand nonAnonymousSendCommand, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings, - ICurrentContext currentContext) + GlobalSettings globalSettings) { _sendRepository = sendRepository; _userService = userService; @@ -54,7 +51,6 @@ public class SendsController : Controller _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; - _currentContext = currentContext; } #region Anonymous endpoints diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index f4f1830e16..2c6dc8b73b 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -62,9 +62,9 @@ public static class ApiHelpers } } - if (eventTypeHandlers.ContainsKey(eventGridEvent.EventType)) + if (eventTypeHandlers.TryGetValue(eventGridEvent.EventType, out var eventTypeHandler)) { - await eventTypeHandlers[eventGridEvent.EventType](eventGridEvent); + await eventTypeHandler(eventGridEvent); } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 251362589e..5991d0babb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -42,7 +42,6 @@ public class CiphersController : Controller private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly IFeatureService _featureService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; @@ -57,7 +56,6 @@ public class CiphersController : Controller ICurrentContext currentContext, ILogger logger, GlobalSettings globalSettings, - IFeatureService featureService, IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository) @@ -71,7 +69,6 @@ public class CiphersController : Controller _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; - _featureService = featureService; _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; @@ -375,11 +372,6 @@ public class CiphersController : Controller private async Task CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable cipherIds) { - if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion)) - { - return await CanEditCipherAsAdminAsync(organizationId, cipherIds); - } - var org = _currentContext.GetOrganization(organizationId); // If we're not an "admin" or if we're a provider user we don't need to check the ciphers @@ -1064,7 +1056,7 @@ public class CiphersController : Controller [HttpPut("share")] [HttpPost("share")] - public async Task PutShareMany([FromBody] CipherBulkShareRequestModel model) + public async Task> PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); if (!await _currentContext.OrganizationUser(organizationId)) @@ -1073,38 +1065,41 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, withOrganizations: false); var ciphersDict = ciphers.ToDictionary(c => c.Id); // Validate the model was encrypted for the posting user foreach (var cipher in model.Ciphers) { - if (cipher.EncryptedFor != null) + if (cipher.EncryptedFor.HasValue && cipher.EncryptedFor.Value != userId) { - if (cipher.EncryptedFor != userId) - { - throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); - } + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } - var shareCiphers = new List<(Cipher, DateTime?)>(); + var shareCiphers = new List<(CipherDetails, DateTime?)>(); foreach (var cipher in model.Ciphers) { - if (!ciphersDict.ContainsKey(cipher.Id.Value)) + if (!ciphersDict.TryGetValue(cipher.Id.Value, out var existingCipher)) { - throw new BadRequestException("Trying to move ciphers that you do not own."); + throw new BadRequestException("Trying to share ciphers that you do not own."); } - var existingCipher = ciphersDict[cipher.Id.Value]; - ValidateClientVersionForFido2CredentialSupport(existingCipher); - shareCiphers.Add((cipher.ToCipher(existingCipher), cipher.LastKnownRevisionDate)); + shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); } - await _cipherService.ShareManyAsync(shareCiphers, organizationId, - model.CollectionIds.Select(c => new Guid(c)), userId); + var updated = await _cipherService.ShareManyAsync( + shareCiphers, + organizationId, + model.CollectionIds.Select(Guid.Parse), + userId + ); + + var response = updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + return new ListResponseModel(response); } [HttpPost("purge")] @@ -1186,14 +1181,14 @@ public class CiphersController : Controller var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { throw new NotFoundException(); } return new AttachmentUploadDataResponseModel { - Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachments[attachmentId]), + Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachment), FileUploadType = _attachmentStorageService.FileUploadType, }; } @@ -1212,11 +1207,10 @@ public class CiphersController : Controller var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId)) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData)) { throw new NotFoundException(); } - var attachmentData = attachments[attachmentId]; await Request.GetFileAsync(async (stream) => { @@ -1366,7 +1360,7 @@ public class CiphersController : Controller var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId)); var attachments = cipher?.GetAttachments() ?? new Dictionary(); - if (cipher == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (cipher == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { if (_attachmentStorageService is AzureSendFileStorageService azureFileStorageService) { @@ -1376,7 +1370,7 @@ public class CiphersController : Controller return; } - await _cipherService.ValidateCipherAttachmentFile(cipher, attachments[attachmentId]); + await _cipherService.ValidateCipherAttachmentFile(cipher, attachment); } catch (Exception e) { diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 2fe1025ba7..d94c9a9a92 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -1,9 +1,7 @@ using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; -using Bit.Core; using Bit.Core.Services; -using Bit.Core.Utilities; using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; @@ -15,7 +13,6 @@ namespace Bit.Api.Vault.Controllers; [Route("tasks")] [Authorize("Application")] -[RequireFeature(FeatureFlagKeys.SecurityTasks)] public class SecurityTaskController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 5c288ab66d..229d27e484 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -113,18 +113,25 @@ public class CipherRequestModel if (hasAttachments2) { - foreach (var attachment in attachments.Where(a => Attachments2.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - var attachment2 = Attachments2[attachment.Key]; + if (!Attachments2.TryGetValue(attachment.Key, out var attachment2)) + { + continue; + } attachment.Value.FileName = attachment2.FileName; attachment.Value.Key = attachment2.Key; } } else if (hasAttachments) { - foreach (var attachment in attachments.Where(a => Attachments.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - attachment.Value.FileName = Attachments[attachment.Key]; + if (!Attachments.TryGetValue(attachment.Key, out var attachmentForKey)) + { + continue; + } + attachment.Value.FileName = attachmentForKey; attachment.Value.Key = null; } } diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 358da3e62a..240783837e 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -129,13 +129,13 @@ public class CipherDetailsResponseModel : CipherResponseModel IDictionary> collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -147,7 +147,7 @@ public class CipherDetailsResponseModel : CipherResponseModel IEnumerable collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? new List(); + CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? []; } public CipherDetailsResponseModel( @@ -158,7 +158,7 @@ public class CipherDetailsResponseModel : CipherResponseModel string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public IEnumerable CollectionIds { get; set; } @@ -170,13 +170,13 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel IDictionary> collectionCiphers, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -184,7 +184,7 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel GlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, diff --git a/src/Api/entrypoint.sh b/src/Api/entrypoint.sh index 37d117215c..d89a4648ec 100644 --- a/src/Api/entrypoint.sh +++ b/src/Api/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Api.dll +exec $gosu_cmd /app/Api diff --git a/src/Billing/.dockerignore b/src/Billing/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Billing/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 01a8bbdd9b..116efdb68c 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Billing/Controllers/AppleController.cs b/src/Billing/Controllers/AppleController.cs index 1bcbbf2ad6..5c231de8ed 100644 --- a/src/Billing/Controllers/AppleController.cs +++ b/src/Billing/Controllers/AppleController.cs @@ -28,8 +28,8 @@ public class AppleController : Controller return new BadRequestResult(); } - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) ? + keyValue.ToString() : null; if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.AppleWebhookKey)) { return new BadRequestResult(); diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 2afde80601..36987c6e44 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -51,8 +51,8 @@ public class PayPalController : Controller [HttpPost("ipn")] public async Task PostIpn() { - var key = HttpContext.Request.Query.ContainsKey("key") - ? HttpContext.Request.Query["key"].ToString() + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) + ? keyValue.ToString() : null; if (string.IsNullOrEmpty(key)) diff --git a/src/Billing/Dockerfile b/src/Billing/Dockerfile index 9abbe16477..5eb4e9c0e0 100644 --- a/src/Billing/Dockerfile +++ b/src/Billing/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Billing +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -8,14 +52,11 @@ RUN apt-get update \ curl \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY entrypoint.sh / +COPY --from=build /source/src/Billing/out /app +COPY ./src/Billing/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - -COPY obj/build-output/publish . - HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 33e2665427..3e005ce7fd 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -20,8 +20,8 @@ public class Program return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs; } - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs index 6deb0bc330..fe7745f760 100644 --- a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs @@ -1,8 +1,4 @@ -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; +using Bit.Core.Repositories; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -10,23 +6,17 @@ namespace Bit.Billing.Services.Implementations; public class CustomerUpdatedHandler : ICustomerUpdatedHandler { private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly ILogger _logger; public CustomerUpdatedHandler( IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, ILogger logger) { _organizationRepository = organizationRepository ?? throw new ArgumentNullException(nameof(organizationRepository)); - _referenceEventService = referenceEventService; - _currentContext = currentContext; _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _logger = logger; @@ -95,20 +85,5 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler organization.BillingEmail = customer.Email; await _organizationRepository.ReplaceAsync(organization); - - if (_referenceEventService == null) - { - _logger.LogError("ReferenceEventService was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_referenceEventService)} is not initialized"); - } - - if (_currentContext == null) - { - _logger.LogError("CurrentContext was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_currentContext)} is not initialized"); - } - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); } } diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 40d8c8349d..4c256e3d85 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,13 +3,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Context; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -22,9 +18,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler private readonly IStripeFacade _stripeFacade; private readonly IProviderRepository _providerRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; - private readonly IUserRepository _userRepository; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationEnableCommand _organizationEnableCommand; @@ -36,9 +29,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler IStripeFacade stripeFacade, IProviderRepository providerRepository, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, - IUserRepository userRepository, IStripeEventUtilityService stripeEventUtilityService, IUserService userService, IPushNotificationService pushNotificationService, @@ -50,9 +40,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _stripeFacade = stripeFacade; _providerRepository = providerRepository; _organizationRepository = organizationRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; - _userRepository = userRepository; _stripeEventUtilityService = stripeEventUtilityService; _userService = userService; _pushNotificationService = pushNotificationService; @@ -116,27 +103,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); - - return; } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.TeamsMonthly, - Seats = (int)teamsMonthlyLineItem.Quantity - }); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.EnterpriseMonthly, - Seats = (int)enterpriseMonthlyLineItem.Quantity - }); } else if (organizationId.HasValue) { @@ -156,15 +123,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) - { - PlanName = organization?.Plan, - PlanType = organization?.PlanType, - Seats = organization?.Seats, - Storage = organization?.MaxStorageGb, - }); } else if (userId.HasValue) { @@ -174,14 +132,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler } await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); - - var user = await _userRepository.GetByIdAsync(userId.Value); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) - { - PlanName = IStripeEventUtilityService.PremiumPlanId, - Storage = user?.MaxStorageGb, - }); } } } diff --git a/src/Billing/entrypoint.sh b/src/Billing/entrypoint.sh index 6d98cfa6f6..66540416f5 100644 --- a/src/Billing/entrypoint.sh +++ b/src/Billing/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,25 +19,27 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Billing.dll +exec $gosu_cmd /app/Billing diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index e649406bb0..274c7f8ddb 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -8,14 +8,13 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Services; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; #nullable enable namespace Bit.Core.AdminConsole.Entities; -public class Organization : ITableObject, IStorableSubscriber, IRevisable, IReferenceable +public class Organization : ITableObject, IStorableSubscriber, IRevisable { private Dictionary? _twoFactorProviders; @@ -258,12 +257,12 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public bool TwoFactorProviderIsEnabled(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) + if (providers == null || !providers.TryGetValue(provider, out var twoFactorProvider)) { return false; } - return providers[provider].Enabled && Use2fa; + return twoFactorProvider.Enabled && Use2fa; } public bool TwoFactorIsEnabled() @@ -280,12 +279,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) - { - return null; - } - - return providers[provider]; + return providers?.GetValueOrDefault(provider); } public void UpdateFromLicense(OrganizationLicense license, IFeatureService featureService) diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index 9828482a7e..3166ebf3a8 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Enums; using Bit.Core.Models; @@ -9,23 +10,75 @@ using Bit.Core.Utilities; namespace Bit.Core.Entities; +/// +/// An association table between one and one , representing that user's +/// membership in the organization. "Member" refers to the OrganizationUser object. +/// public class OrganizationUser : ITableObject, IExternal, IOrganizationUser { + /// + /// A unique random identifier. + /// public Guid Id { get; set; } + /// + /// The ID of the Organization that the user is a member of. + /// public Guid OrganizationId { get; set; } + /// + /// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because + /// it is not linked to a specific User yet. + /// public Guid? UserId { get; set; } + /// + /// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or + /// Invited and then Revoked), because in that case the OrganizationUser is linked to a User + /// and the email is stored on the User object. + /// [MaxLength(256)] public string? Email { get; set; } + /// + /// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed + /// (or Confirmed and then Revoked) status. + /// public string? Key { get; set; } + /// + /// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser + /// is not enrolled in account recovery. + /// public string? ResetPasswordKey { get; set; } + /// public OrganizationUserStatusType Status { get; set; } + /// + /// The User's role in the Organization. + /// public OrganizationUserType Type { get; set; } - + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// [MaxLength(300)] public string? ExternalId { get; set; } + /// + /// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization. + /// public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + /// + /// The last date the OrganizationUser entry was updated. + /// public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + /// + /// A json blob representing the of the OrganizationUser if they + /// are a Custom user role (i.e. the is Custom). MAY be NULL if they are not + /// a custom user, but this is not guaranteed; do not use this to determine their role. + /// + /// + /// Avoid using this property directly - instead use the and + /// helper methods. + /// public string? Permissions { get; set; } + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// public bool AccessSecretsManager { get; set; } public void SetNewId() diff --git a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs index 576e98ea74..3b4098715d 100644 --- a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs +++ b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs @@ -1,9 +1,34 @@ -namespace Bit.Core.Enums; +using Bit.Core.Entities; +namespace Bit.Core.Enums; + +/// +/// Represents the different stages of a member's lifecycle in an organization. +/// The object is populated differently depending on their Status. +/// public enum OrganizationUserStatusType : short { + /// + /// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a + /// specific User yet. + /// Invited = 0, + /// + /// The User has accepted the invitation and linked their User account to the OrganizationUser entry. + /// Accepted = 1, + /// + /// An administrator has granted the User access to the organization. This is the final step in the User becoming + /// a "full" member of the organization, including a key exchange so that they can decrypt organization data. + /// Confirmed = 2, + /// + /// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state. + /// + /// + /// An OrganizationUser may move into this status from any other status, and will move back to their original status + /// if restored. This allows an administrator to easily suspend and restore access without going through the + /// Invite flow again. + /// Revoked = -1, } diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 6f3bcd0102..f72637f862 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -17,6 +17,7 @@ public enum PolicyType : byte AutomaticAppLogIn = 12, FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, + RestrictedItemTypesPolicy = 15, } public static class PolicyTypeExtensions @@ -43,7 +44,8 @@ public static class PolicyTypeExtensions PolicyType.ActivateAutofill => "Active auto-fill", PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", - PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN" + PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", + PolicyType.RestrictedItemTypesPolicy => "Restricted item types", }; } } diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs index bd1f280cad..c94794765b 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs @@ -1,12 +1,15 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.Integrations; public interface IIntegrationMessage { IntegrationType IntegrationType { get; } - int RetryCount { get; set; } - DateTime? DelayUntilDate { get; set; } + string MessageId { get; set; } + int RetryCount { get; } + DateTime? DelayUntilDate { get; } void ApplyRetry(DateTime? handlerDelayUntilDate); string ToJson(); } diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs index d2f0bde693..ecf5d25c51 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public class IntegrationHandlerResult { diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs index 1f288914d0..018d453cb9 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs @@ -1,13 +1,15 @@ -using System.Text.Json; +#nullable enable + +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.Integrations; -public class IntegrationMessage : IIntegrationMessage +public class IntegrationMessage : IIntegrationMessage { public IntegrationType IntegrationType { get; set; } - public T Configuration { get; set; } - public string RenderedTemplate { get; set; } + public required string MessageId { get; set; } + public required string RenderedTemplate { get; set; } public int RetryCount { get; set; } = 0; public DateTime? DelayUntilDate { get; set; } @@ -22,12 +24,22 @@ public class IntegrationMessage : IIntegrationMessage DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds); } - public string ToJson() + public virtual string ToJson() + { + return JsonSerializer.Serialize(this); + } +} + +public class IntegrationMessage : IntegrationMessage +{ + public required T Configuration { get; set; } + + public override string ToJson() { return JsonSerializer.Serialize(this); } - public static IntegrationMessage FromJson(string json) + public static IntegrationMessage? FromJson(string json) { return JsonSerializer.Deserialize>(json); } diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs index 4fcce542ce..4f2c434ff6 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs @@ -1,3 +1,5 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegration(string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs index 2930004cbf..18b13248ec 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs @@ -1,3 +1,5 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegrationConfiguration(string channelId); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs index b81e50d403..a9b4150419 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs @@ -1,3 +1,5 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegrationConfigurationDetails(string channelId, string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs index e8217d3ad3..47e014ee2a 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs @@ -1,3 +1,5 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record WebhookIntegrationConfiguration(string url); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs index e3e92c900f..c4c41db24f 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs @@ -1,3 +1,5 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record WebhookIntegrationConfigurationDetails(string url); diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index 59debed746..ede2123f7e 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -1,4 +1,5 @@ - +#nullable enable + using System.Text.Json.Serialization; namespace Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index 11bf6d7f66..f514beed38 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -1,15 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups; @@ -18,21 +14,16 @@ public class CreateGroupCommand : ICreateGroupCommand private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; public CreateGroupCommand( IEventService eventService, IGroupRepository groupRepository, - IOrganizationUserRepository organizationUserRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext) + IOrganizationUserRepository organizationUserRepository + ) { _eventService = eventService; _groupRepository = groupRepository; _organizationUserRepository = organizationUserRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; } public async Task CreateGroupAsync(Group group, Organization organization, @@ -77,8 +68,6 @@ public class CreateGroupCommand : ICreateGroupCommand { await _groupRepository.CreateAsync(group, collections); } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.GroupCreated, organization, _currentContext)); } private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable userIds, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index f3426efddc..3770d867cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -1,4 +1,6 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -27,6 +29,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IUserRepository _userRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -37,9 +41,10 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand IMailService mailService, IUserRepository userRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory) + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { - // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); _globalSettings = globalSettings; @@ -50,6 +55,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _userRepository = userRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -196,15 +203,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); - } - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, orgUser.OrganizationId); orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.UserId = user.Id; @@ -224,4 +223,33 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand return orgUser; } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + + return; + } + + if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); + if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 9bfe8f791e..806cf5a533 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -24,6 +26,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly IPolicyService _policyService; private readonly IDeviceRepository _deviceRepository; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, @@ -35,7 +39,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, IPolicyService policyService, - IDeviceRepository deviceRepository) + IDeviceRepository deviceRepository, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -47,6 +53,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _policyService = policyService; _deviceRepository = deviceRepository; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, @@ -118,8 +126,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } - var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; - await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); + var userTwoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; + await CheckPoliciesAsync(organizationId, user, orgUsers, userTwoFactorEnabled); orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; @@ -142,15 +150,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool twoFactorEnabled) + ICollection userOrgs, bool userTwoFactorEnabled) { // Enforce Two Factor Authentication Policy for this organization - var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) - .Any(p => p.OrganizationId == organizationId); - if (orgRequiresTwoFactor && !twoFactorEnabled) - { - throw new BadRequestException("User does not have two-step login enabled."); - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); @@ -168,6 +171,33 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId, bool userTwoFactorEnabled) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (userTwoFactorEnabled) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + + return; + } + + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !userTwoFactorEnabled) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + } + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) { var devices = await GetUserDeviceIdsAsync(userId); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs index 49ddf0a548..60a1c8bfbf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs @@ -7,9 +7,6 @@ using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; #nullable enable @@ -24,7 +21,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IReferenceEventService _referenceEventService; private readonly IPushNotificationService _pushService; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderUserRepository _providerUserRepository; @@ -36,7 +32,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz IUserRepository userRepository, ICurrentContext currentContext, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IReferenceEventService referenceEventService, IPushNotificationService pushService, IOrganizationRepository organizationRepository, IProviderUserRepository providerUserRepository) @@ -48,7 +43,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz _userRepository = userRepository; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _referenceEventService = referenceEventService; _pushService = pushService; _organizationRepository = organizationRepository; _providerUserRepository = providerUserRepository; @@ -195,8 +189,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz await _userRepository.DeleteManyAsync(users); foreach (var user in users) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext)); await _pushService.PushLogOutAsync(user.Id); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 072bc5fc05..1dddc8bf0c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -9,15 +9,11 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Microsoft.Extensions.Logging; using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; @@ -28,8 +24,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, IInviteUsersValidator inviteUsersValidator, IPaymentService paymentService, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IApplicationCacheService applicationCacheService, IMailService mailService, ILogger logger, @@ -93,7 +87,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, InviteOrganization = request.InviteOrganization, PerformedBy = request.PerformedBy, PerformedAt = request.PerformedAt, - OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), + OccupiedPmSeats = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)).Total, OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) }); @@ -121,8 +115,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await SendAdditionalEmailsAsync(validatedRequest, organization); await SendInvitesAsync(organizationUserToInviteEntities, organization); - - await PublishReferenceEventAsync(validatedRequest, organization); } catch (Exception ex) { @@ -190,14 +182,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } - private async Task PublishReferenceEventAsync(Valid validatedResult, - Organization organization) => - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) - { - Users = validatedResult.Value.Invites.Length - }); - private async Task SendInvitesAsync(IEnumerable users, Organization organization) => await sendOrganizationInvitesCommand.SendInvitesAsync( new SendInvitesRequest( @@ -284,15 +268,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update await applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) - { - PlanName = validatedResult.Value.InviteOrganization.Plan.Name, - PlanType = validatedResult.Value.InviteOrganization.Plan.Type, - Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, - PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats - }); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index a1536ad439..f5259d1066 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Validation; @@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator( return invalidEnvironment.Map(request); } - var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); - - if (organizationValidationResult is Invalid organizationValidation) - { - return organizationValidation.Map(request); - } - + // Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing. var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId); + if (provider is not null) { var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider)); @@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator( } } + var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); + + if (organizationValidationResult is Invalid organizationValidation) + { + return organizationValidation.Map(request); + } + var paymentSubscription = await paymentService.GetSubscriptionAsync( await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs index 496dddc916..6de219f1cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -1,10 +1,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; public static class InviteUserPaymentValidation { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 74165a5a71..0d9955eecf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -22,7 +24,9 @@ public class RestoreOrganizationUserCommand( ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPolicyService policyService, IUserRepository userRepository, - IOrganizationService organizationService) : IRestoreOrganizationUserCommand + IOrganizationService organizationService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) { @@ -66,8 +70,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; if (availableSeats < 1) { @@ -159,8 +163,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); @@ -270,12 +274,7 @@ public class RestoreOrganizationUserCommand( // Enforce 2FA Policy of organization user is trying to join if (!userHasTwoFactorEnabled) { - var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } + twoFactorCompliant = !await IsTwoFactorRequiredForOrganizationAsync(userId, orgUser.OrganizationId); } var user = await userRepository.GetByIdAsync(userId); @@ -299,4 +298,17 @@ public class RestoreOrganizationUserCommand( throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } } + + private async Task IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var requirement = await policyRequirementQuery.GetAsync(userId); + return requirement.IsTwoFactorRequiredForOrganization(organizationId); + } + + var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); + return invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 7449628ed0..f26061cbd2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -5,7 +5,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -15,9 +14,6 @@ using Bit.Core.Models.StaticStore; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -36,8 +32,6 @@ public class CloudOrganizationSignUpCommand( IOrganizationBillingService organizationBillingService, IPaymentService paymentService, IPolicyService policyService, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -132,17 +126,6 @@ public class CloudOrganizationSignUpCommand( var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - }); - return new SignUpOrganizationResponse(returnValue.organization, returnValue.organizationUser); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index 185d5c5ac0..6a81130402 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -2,38 +2,28 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IApplicationCacheService _applicationCacheService; - private readonly ICurrentContext _currentContext; private readonly IOrganizationRepository _organizationRepository; private readonly IPaymentService _paymentService; - private readonly IReferenceEventService _referenceEventService; private readonly ISsoConfigRepository _ssoConfigRepository; public OrganizationDeleteCommand( IApplicationCacheService applicationCacheService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IReferenceEventService referenceEventService, ISsoConfigRepository ssoConfigRepository) { _applicationCacheService = applicationCacheService; - _currentContext = currentContext; _organizationRepository = organizationRepository; _paymentService = paymentService; - _referenceEventService = referenceEventService; _ssoConfigRepository = ssoConfigRepository; } @@ -48,8 +38,6 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand var eop = !organization.ExpirationDate.HasValue || organization.ExpirationDate.Value >= DateTime.UtcNow; await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, organization, _currentContext)); } catch (GatewayException) { } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index b8802ffd0c..c3e945b65f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -8,9 +8,6 @@ using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -37,7 +34,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati private readonly ICurrentContext _currentContext; private readonly IPricingClient _pricingClient; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly IApplicationCacheService _applicationCacheService; @@ -46,7 +42,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati public ProviderClientOrganizationSignUpCommand( ICurrentContext currentContext, IPricingClient pricingClient, - IReferenceEventService referenceEventService, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -54,7 +49,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati { _currentContext = currentContext; _pricingClient = pricingClient; - _referenceEventService = referenceEventService; _organizationRepository = organizationRepository; _organizationApiKeyRepository = organizationApiKeyRepository; _applicationCacheService = applicationCacheService; @@ -108,16 +102,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati var returnValue = await SignUpAsync(organization, signup.CollectionName); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Organization.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Organization.MaxStorageGb, - }); - return returnValue; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index cf332e689a..71212aaf4c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -104,8 +104,8 @@ public class SavePolicyCommand : ISavePolicyCommand var dependentPolicyTypes = _policyValidators.Values .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyUpdate.Type)) .Select(otherValidator => otherValidator.Type) - .Where(otherPolicyType => savedPoliciesDict.ContainsKey(otherPolicyType) && - savedPoliciesDict[otherPolicyType].Enabled) + .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && + savedPolicy.Enabled) .ToList(); switch (dependentPolicyTypes) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs new file mode 100644 index 0000000000..bbc997a83d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Require Two-Factor Authentication policy. +/// +public class RequireTwoFactorPolicyRequirement : IPolicyRequirement +{ + private readonly IEnumerable _policyDetails; + + public RequireTwoFactorPolicyRequirement(IEnumerable policyDetails) + { + _policyDetails = policyDetails; + } + + /// + /// Checks if two-factor authentication is required for the organization due to an active policy. + /// + /// The ID of the organization to check. + /// True if two-factor authentication is required for the organization, false otherwise. + /// + /// This should be used to check whether the member needs to have 2FA enabled before being + /// accepted, confirmed, or restored to the organization. + /// + public bool IsTwoFactorRequiredForOrganization(Guid organizationId) => + _policyDetails.Any(p => p.OrganizationId == organizationId); + + /// + /// Returns tuples of (OrganizationId, OrganizationUserId) for active memberships where two-factor authentication is required. + /// Users should be revoked from these organizations if they disable all 2FA methods. + /// + public IEnumerable<(Guid OrganizationId, Guid OrganizationUserId)> OrganizationsRequiringTwoFactor => + _policyDetails + .Where(p => p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed) + .Select(p => (p.OrganizationId, p.OrganizationUserId)); +} + +public class RequireTwoFactorPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.TwoFactorAuthentication; + protected override IEnumerable ExemptStatuses => []; + + public override RequireTwoFactorPolicyRequirement Create(IEnumerable policyDetails) + { + return new RequireTwoFactorPolicyRequirement(policyDetails); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 1be0e61af7..f98135b70d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -36,5 +36,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, ResetPasswordPolicyRequirementFactory>(); services.AddScoped, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped, RequireSsoPolicyRequirementFactory>(); + services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index 13cc935eb9..5ce72df6c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); } - await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => - _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email))); + await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser => + _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email))); } private static bool MembersWithNoMasterPasswordWillLoseAccess( diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 7e315ed58b..7fff0d437f 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; #nullable enable @@ -25,4 +26,14 @@ public interface IOrganizationRepository : IRepository Task> GetByVerifiedUserEmailDomainAsync(Guid userId); Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task> GetManyByIdsAsync(IEnumerable ids); + + /// + /// Returns the number of occupied seats for an organization. + /// OrganizationUsers occupy a seat, unless they are revoked. + /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an + /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. + /// + /// The ID of the organization to get the occupied seat count for. + /// The number of occupied seats for the organization. + Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 9692de897c..6e07bd9ff8 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -18,16 +18,6 @@ public interface IOrganizationUserRepository : IRepository> GetManyByUserAsync(Guid userId); Task> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type); Task GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); - - /// - /// Returns the number of occupied seats for an organization. - /// Occupied seats are OrganizationUsers that have at least been invited. - /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an - /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. - /// - /// The ID of the organization to get the occupied seat count for. - /// The number of occupied seats for the organization. - Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers); Task GetByOrganizationAsync(Guid organizationId, Guid userId); Task>> GetByIdWithCollectionsAsync(Guid id); diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs index 60b8789a6b..ec2db121db 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs @@ -1,13 +1,87 @@ -using Microsoft.Extensions.Hosting; +#nullable enable + +using System.Text.Json; +using Bit.Core.Models.Data; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Bit.Core.Services; public abstract class EventLoggingListenerService : BackgroundService { protected readonly IEventMessageHandler _handler; + protected ILogger _logger; - protected EventLoggingListenerService(IEventMessageHandler handler) + protected EventLoggingListenerService(IEventMessageHandler handler, ILogger logger) { - _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + _handler = handler; + _logger = logger; + } + + internal async Task ProcessReceivedMessageAsync(string body, string? messageId) + { + try + { + using var jsonDocument = JsonDocument.Parse(body); + var root = jsonDocument.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + var eventMessages = root.Deserialize>(); + await _handler.HandleManyEventsAsync(eventMessages); + } + else if (root.ValueKind == JsonValueKind.Object) + { + var eventMessage = root.Deserialize(); + await _handler.HandleEventAsync(eventMessage); + } + else + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError("An error occurred while processing message: {MessageId} - Invalid JSON", messageId); + } + else + { + _logger.LogError("An Invalid JSON error occurred while processing a message with an empty message id"); + } + } + } + catch (JsonException exception) + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError( + exception, + "An error occurred while processing message: {MessageId} - Invalid JSON", + messageId + ); + } + else + { + _logger.LogError( + exception, + "An Invalid JSON error occurred while processing a message with an empty message id" + ); + } + } + catch (Exception exception) + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError( + exception, + "An error occurred while processing message: {MessageId}", + messageId + ); + } + else + { + _logger.LogError( + exception, + "An error occurred while processing a message with an empty message id" + ); + } + } } } diff --git a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs new file mode 100644 index 0000000000..d254e763d5 --- /dev/null +++ b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs @@ -0,0 +1,10 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.Integrations; + +namespace Bit.Core.Services; + +public interface IAzureServiceBusService : IEventIntegrationPublisher, IAsyncDisposable +{ + ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options); + Task PublishToRetryAsync(IIntegrationMessage message); +} diff --git a/src/Core/AdminConsole/Services/IIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs similarity index 58% rename from src/Core/AdminConsole/Services/IIntegrationPublisher.cs rename to src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs index 986ea776e1..560da576b7 100644 --- a/src/Core/AdminConsole/Services/IIntegrationPublisher.cs +++ b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs @@ -2,7 +2,8 @@ namespace Bit.Core.Services; -public interface IIntegrationPublisher +public interface IEventIntegrationPublisher : IAsyncDisposable { Task PublishAsync(IIntegrationMessage message); + Task PublishEventAsync(string body); } diff --git a/src/Core/AdminConsole/Services/IRabbitMqService.cs b/src/Core/AdminConsole/Services/IRabbitMqService.cs new file mode 100644 index 0000000000..b0b9a72eac --- /dev/null +++ b/src/Core/AdminConsole/Services/IRabbitMqService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Models.Data.Integrations; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public interface IRabbitMqService : IEventIntegrationPublisher +{ + Task CreateChannelAsync(CancellationToken cancellationToken = default); + Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default); + Task CreateIntegrationQueuesAsync( + string queueName, + string retryQueueName, + string routingKey, + CancellationToken cancellationToken = default); + Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken); + Task PublishToDeadLetterAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken); + Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs); +} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs index 4cd71ae77e..8b00204775 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs @@ -1,7 +1,7 @@ -using System.Text; -using System.Text.Json; +#nullable enable + +using System.Text; using Azure.Messaging.ServiceBus; -using Bit.Core.Models.Data; using Bit.Core.Settings; using Microsoft.Extensions.Logging; @@ -9,67 +9,47 @@ namespace Bit.Core.Services; public class AzureServiceBusEventListenerService : EventLoggingListenerService { - private readonly ILogger _logger; - private readonly ServiceBusClient _client; private readonly ServiceBusProcessor _processor; public AzureServiceBusEventListenerService( IEventMessageHandler handler, - ILogger logger, + IAzureServiceBusService serviceBusService, + string subscriptionName, GlobalSettings globalSettings, - string subscriptionName) : base(handler) + ILogger logger) : base(handler, logger) { - _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _processor = _client.CreateProcessor(globalSettings.EventLogging.AzureServiceBus.TopicName, subscriptionName, new ServiceBusProcessorOptions()); + _processor = serviceBusService.CreateProcessor( + globalSettings.EventLogging.AzureServiceBus.EventTopicName, + subscriptionName, + new ServiceBusProcessorOptions()); _logger = logger; } protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - _processor.ProcessMessageAsync += async args => - { - try - { - using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(args.Message.Body)); - var root = jsonDocument.RootElement; - - if (root.ValueKind == JsonValueKind.Array) - { - var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); - } - else if (root.ValueKind == JsonValueKind.Object) - { - var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); - - } - await args.CompleteMessageAsync(args.Message); - } - catch (Exception exception) - { - _logger.LogError( - exception, - "An error occured while processing message: {MessageId}", - args.Message.MessageId - ); - } - }; - - _processor.ProcessErrorAsync += args => - { - _logger.LogError( - args.Exception, - "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", - args.EntityPath, - args.ErrorSource - ); - return Task.CompletedTask; - }; + _processor.ProcessMessageAsync += ProcessReceivedMessageAsync; + _processor.ProcessErrorAsync += ProcessErrorAsync; await _processor.StartProcessingAsync(cancellationToken); } + internal Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + _logger.LogError( + args.Exception, + "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", + args.EntityPath, + args.ErrorSource + ); + return Task.CompletedTask; + } + + private async Task ProcessReceivedMessageAsync(ProcessMessageEventArgs args) + { + await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId); + await args.CompleteMessageAsync(args.Message); + } + public override async Task StopAsync(CancellationToken cancellationToken) { await _processor.StopProcessingAsync(cancellationToken); @@ -79,7 +59,6 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService public override void Dispose() { _processor.DisposeAsync().GetAwaiter().GetResult(); - _client.DisposeAsync().GetAwaiter().GetResult(); base.Dispose(); } } diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs deleted file mode 100644 index fc865b327c..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.Json; -using Azure.Messaging.ServiceBus; -using Bit.Core.Models.Data; -using Bit.Core.Services; -using Bit.Core.Settings; - -namespace Bit.Core.AdminConsole.Services.Implementations; - -public class AzureServiceBusEventWriteService : IEventWriteService, IAsyncDisposable -{ - private readonly ServiceBusClient _client; - private readonly ServiceBusSender _sender; - - public AzureServiceBusEventWriteService(GlobalSettings globalSettings) - { - _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.TopicName); - } - - public async Task CreateAsync(IEvent e) - { - var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(e)) - { - ContentType = "application/json" - }; - - await _sender.SendMessageAsync(message); - } - - public async Task CreateManyAsync(IEnumerable events) - { - var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(events)) - { - ContentType = "application/json" - }; - - await _sender.SendMessageAsync(message); - } - - public async ValueTask DisposeAsync() - { - await _sender.DisposeAsync(); - await _client.DisposeAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs new file mode 100644 index 0000000000..55a39ec774 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs @@ -0,0 +1,107 @@ +#nullable enable + +using Azure.Messaging.ServiceBus; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class AzureServiceBusIntegrationListenerService : BackgroundService +{ + private readonly int _maxRetries; + private readonly IAzureServiceBusService _serviceBusService; + private readonly IIntegrationHandler _handler; + private readonly ServiceBusProcessor _processor; + private readonly ILogger _logger; + + public AzureServiceBusIntegrationListenerService(IIntegrationHandler handler, + string topicName, + string subscriptionName, + int maxRetries, + IAzureServiceBusService serviceBusService, + ILogger logger) + { + _handler = handler; + _logger = logger; + _maxRetries = maxRetries; + _serviceBusService = serviceBusService; + + _processor = _serviceBusService.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions()); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _processor.ProcessMessageAsync += HandleMessageAsync; + _processor.ProcessErrorAsync += ProcessErrorAsync; + + await _processor.StartProcessingAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _processor.StopProcessingAsync(cancellationToken); + await _processor.DisposeAsync(); + await base.StopAsync(cancellationToken); + } + + internal Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + _logger.LogError( + args.Exception, + "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", + args.EntityPath, + args.ErrorSource + ); + return Task.CompletedTask; + } + + internal async Task HandleMessageAsync(string body) + { + try + { + var result = await _handler.HandleAsync(body); + var message = result.Message; + + if (result.Success) + { + // Successful integration. Return true to indicate the message has been handled + return true; + } + + message.ApplyRetry(result.DelayUntilDate); + + if (result.Retryable && message.RetryCount < _maxRetries) + { + // Publish message to the retry queue. It will be re-published for retry after a delay + // Return true to indicate the message has been handled + await _serviceBusService.PublishToRetryAsync(message); + return true; + } + else + { + // Non-recoverable failure or exceeded the max number of retries + // Return false to indicate this message should be dead-lettered + return false; + } + } + catch (Exception ex) + { + // Unknown exception - log error, return true so the message will be acknowledged and not resent + _logger.LogError(ex, "Unhandled error processing ASB message"); + return true; + } + } + + private async Task HandleMessageAsync(ProcessMessageEventArgs args) + { + var json = args.Message.Body.ToString(); + if (await HandleMessageAsync(json)) + { + await args.CompleteMessageAsync(args.Message); + } + else + { + await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable"); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs new file mode 100644 index 0000000000..7d24095819 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs @@ -0,0 +1,70 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Services; + +public class AzureServiceBusService : IAzureServiceBusService +{ + private readonly ServiceBusClient _client; + private readonly ServiceBusSender _eventSender; + private readonly ServiceBusSender _integrationSender; + + public AzureServiceBusService(GlobalSettings globalSettings) + { + _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); + _eventSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName); + _integrationSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName); + } + + public ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options) + { + return _client.CreateProcessor(topicName, subscriptionName, options); + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var json = message.ToJson(); + + var serviceBusMessage = new ServiceBusMessage(json) + { + Subject = message.IntegrationType.ToRoutingKey(), + MessageId = message.MessageId + }; + + await _integrationSender.SendMessageAsync(serviceBusMessage); + } + + public async Task PublishToRetryAsync(IIntegrationMessage message) + { + var json = message.ToJson(); + + var serviceBusMessage = new ServiceBusMessage(json) + { + Subject = message.IntegrationType.ToRoutingKey(), + ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow, + MessageId = message.MessageId + }; + + await _integrationSender.SendMessageAsync(serviceBusMessage); + } + + public async Task PublishEventAsync(string body) + { + var message = new ServiceBusMessage(body) + { + ContentType = "application/json", + MessageId = Guid.NewGuid().ToString() + }; + + await _eventSender.SendMessageAsync(message); + } + + public async ValueTask DisposeAsync() + { + await _eventSender.DisposeAsync(); + await _integrationSender.DisposeAsync(); + await _client.DisposeAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs index aa545913b1..578dde9485 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrationEventWriteService.cs new file mode 100644 index 0000000000..519f8aeb32 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrationEventWriteService.cs @@ -0,0 +1,32 @@ +#nullable enable + +using System.Text.Json; +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; +public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDisposable +{ + private readonly IEventIntegrationPublisher _eventIntegrationPublisher; + + public EventIntegrationEventWriteService(IEventIntegrationPublisher eventIntegrationPublisher) + { + _eventIntegrationPublisher = eventIntegrationPublisher; + } + + public async Task CreateAsync(IEvent e) + { + var body = JsonSerializer.Serialize(e); + await _eventIntegrationPublisher.PublishEventAsync(body: body); + } + + public async Task CreateManyAsync(IEnumerable events) + { + var body = JsonSerializer.Serialize(events); + await _eventIntegrationPublisher.PublishEventAsync(body: body); + } + + public async ValueTask DisposeAsync() + { + await _eventIntegrationPublisher.DisposeAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs index 9a80ed67b2..aa76fdf8bc 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +#nullable enable + +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; @@ -7,11 +9,9 @@ using Bit.Core.Repositories; namespace Bit.Core.Services; -#nullable enable - public class EventIntegrationHandler( IntegrationType integrationType, - IIntegrationPublisher integrationPublisher, + IEventIntegrationPublisher eventIntegrationPublisher, IOrganizationIntegrationConfigurationRepository configurationRepository, IUserRepository userRepository, IOrganizationRepository organizationRepository) @@ -34,6 +34,7 @@ public class EventIntegrationHandler( var template = configuration.Template ?? string.Empty; var context = await BuildContextAsync(eventMessage, template); var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); + var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid(); var config = configuration.MergedConfiguration.Deserialize() ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}"); @@ -41,13 +42,14 @@ public class EventIntegrationHandler( var message = new IntegrationMessage { IntegrationType = integrationType, + MessageId = messageId.ToString(), Configuration = config, RenderedTemplate = renderedTemplate, RetryCount = 0, DelayUntilDate = null }; - await integrationPublisher.PublishAsync(message); + await eventIntegrationPublisher.PublishAsync(message); } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs index ee3a2d5db2..0fab787589 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs index a542e75a7b..df0819b409 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index 0cecda61a7..88d9595b4a 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -462,13 +462,13 @@ public class EventService : IEventService private bool CanUseEvents(IDictionary orgAbilities, Guid orgId) { - return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].UseEvents; + return orgAbilities != null && orgAbilities.TryGetValue(orgId, out var orgAbility) && + orgAbility.Enabled && orgAbility.UseEvents; } private bool CanUseProviderEvents(IDictionary providerAbilities, Guid providerId) { - return providerAbilities != null && providerAbilities.ContainsKey(providerId) && - providerAbilities[providerId].Enabled && providerAbilities[providerId].UseEvents; + return providerAbilities != null && providerAbilities.TryGetValue(providerId, out var providerAbility) && + providerAbility.Enabled && providerAbility.UseEvents; } } diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs deleted file mode 100644 index 4df2d25b1b..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.AdminConsole.Utilities; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Repositories; - -namespace Bit.Core.Services; - -public abstract class IntegrationEventHandlerBase( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) - : IEventMessageHandler -{ - public async Task HandleEventAsync(EventMessage eventMessage) - { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - GetIntegrationType(), - eventMessage.Type); - - foreach (var configuration in configurations) - { - var context = await BuildContextAsync(eventMessage, configuration.Template); - var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); - - await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); - } - } - - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } - } - - private async Task BuildContextAsync(EventMessage eventMessage, string template) - { - var context = new IntegrationTemplateContext(eventMessage); - - if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) - { - context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) - { - context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) - { - context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); - } - - return context; - } - - protected abstract IntegrationType GetIntegrationType(); - - protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); -} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 26ff421328..4d709bb7cf 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -30,9 +29,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; @@ -45,7 +41,6 @@ public class OrganizationService : IOrganizationService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionRepository _collectionRepository; - private readonly IUserRepository _userRepository; private readonly IGroupRepository _groupRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; @@ -58,7 +53,6 @@ public class OrganizationService : IOrganizationService private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICurrentContext _currentContext; @@ -69,7 +63,6 @@ public class OrganizationService : IOrganizationService private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; @@ -79,7 +72,6 @@ public class OrganizationService : IOrganizationService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICollectionRepository collectionRepository, - IUserRepository userRepository, IGroupRepository groupRepository, IMailService mailService, IPushNotificationService pushNotificationService, @@ -92,7 +84,6 @@ public class OrganizationService : IOrganizationService IPolicyRepository policyRepository, IPolicyService policyService, ISsoUserRepository ssoUserRepository, - IReferenceEventService referenceEventService, IGlobalSettings globalSettings, IOrganizationApiKeyRepository organizationApiKeyRepository, ICurrentContext currentContext, @@ -103,7 +94,6 @@ public class OrganizationService : IOrganizationService IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IProviderRepository providerRepository, IFeatureService featureService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, @@ -113,7 +103,6 @@ public class OrganizationService : IOrganizationService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; - _userRepository = userRepository; _groupRepository = groupRepository; _mailService = mailService; _pushNotificationService = pushNotificationService; @@ -126,7 +115,6 @@ public class OrganizationService : IOrganizationService _policyRepository = policyRepository; _policyService = policyService; _ssoUserRepository = ssoUserRepository; - _referenceEventService = referenceEventService; _globalSettings = globalSettings; _organizationApiKeyRepository = organizationApiKeyRepository; _currentContext = currentContext; @@ -137,7 +125,6 @@ public class OrganizationService : IOrganizationService _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _providerRepository = providerRepository; _featureService = featureService; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; @@ -160,11 +147,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.CancelSubscription, organization, _currentContext) - { - EndOfPeriod = endOfPeriod, - }); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -176,8 +158,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.ReinstateSubscriptionAsync(organization); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.ReinstateSubscription, organization, _currentContext)); } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -197,13 +177,6 @@ public class OrganizationService : IOrganizationService var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, plan.PasswordManager.StripeStoragePlanId); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustStorage, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Storage = storageAdjustmentGb, - }); await ReplaceAndUpdateCacheAsync(organization); return secret; } @@ -321,11 +294,20 @@ public class OrganizationService : IOrganizationService if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > newSeatTotal) + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + if (seatCounts.Total > newSeatTotal) { - throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + - $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0) + { + throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " + + $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships."); + } + else + { + throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " + + $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + } } } @@ -335,14 +317,6 @@ public class OrganizationService : IOrganizationService } var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = newSeatTotal, - PreviousSeats = organization.Seats - }); organization.Seats = (short?)newSeatTotal; await ReplaceAndUpdateCacheAsync(organization); @@ -647,12 +621,12 @@ public class OrganizationService : IOrganizationService } var providers = organization.GetTwoFactorProviders(); - if (!providers?.ContainsKey(type) ?? true) + if (providers is null || !providers.TryGetValue(type, out var provider)) { return; } - providers[type].Enabled = true; + provider.Enabled = true; organization.SetTwoFactorProviders(providers); await UpdateAsync(organization); } @@ -761,8 +735,8 @@ public class OrganizationService : IOrganizationService var newSeatsRequired = 0; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.Value - occupiedSeats; + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.Value - seatCounts.Total; newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; } @@ -893,12 +867,6 @@ public class OrganizationService : IOrganizationService } await SendInvitesAsync(allOrgUsers, organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, _currentContext) - { - Users = orgUserInvitedCount - }); } catch (Exception e) { @@ -1156,7 +1124,7 @@ public class OrganizationService : IOrganizationService var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); var removeUsersSet = new HashSet(removeUserExternalIds) .Except(newUsersSet) - .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) + .Where(u => existingUsersDict.TryGetValue(u, out var existingUser) && existingUser.Type != OrganizationUserType.Owner) .Select(u => existingUsersDict[u]); await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); @@ -1218,8 +1186,8 @@ public class OrganizationService : IOrganizationService var enoughSeatsAvailable = true; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - seatsAvailable = organization.Seats.Value - occupiedSeats; + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + seatsAvailable = organization.Seats.Value - seatCounts.Total; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } @@ -1324,8 +1292,6 @@ public class OrganizationService : IOrganizationService } await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, eventSystemUser, e.d))); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DirectorySynced, organization, _currentContext)); } public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId) @@ -1722,72 +1688,6 @@ public class OrganizationService : IOrganizationService return result; } - private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) - { - // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant - // The user will be subject to the same checks when they try to accept the invite - if (GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited) - { - return; - } - - var userId = orgUser.UserId.Value; - - // Enforce Single Organization Policy of organization user is being restored to - var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(userId); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); - var singleOrgPoliciesApplyingToRevokedUsers = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); - var singleOrgPolicyApplies = singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); - - var singleOrgCompliant = true; - var belongsToOtherOrgCompliant = true; - var twoFactorCompliant = true; - - if (hasOtherOrgs && singleOrgPolicyApplies) - { - singleOrgCompliant = false; - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - belongsToOtherOrgCompliant = false; - } - - // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!userHasTwoFactorEnabled) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } - } - - var user = await _userRepository.GetByIdAsync(userId); - - if (!singleOrgCompliant && !twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization and two-step login polciy"); - } - else if (!singleOrgCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); - } - else if (!belongsToOtherOrgCompliant) - { - throw new BadRequestException(user.Email + " belongs to an organization that doesn't allow them to join multiple organizations"); - } - else if (!twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); - } - } - public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) { // Determine status to revert back to @@ -1827,11 +1727,5 @@ public class OrganizationService : IOrganizationService await SendInviteAsync(ownerOrganizationUser, organization, true); await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationCreatedByAdmin, organization, _currentContext) - { - EventRaisedByUser = userService.GetUserName(user), - SalesAssistedTrialStarted = salesAssistedTrialStarted, - }); } } diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index c3eb2272d0..d424bd8fff 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -68,7 +68,7 @@ public class PolicyService : IPolicyService var excludedUserTypes = GetUserTypesExcludedFromPolicy(policyType); var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); return organizationUserPolicyDetails.Where(o => - (!orgAbilities.ContainsKey(o.OrganizationId) || orgAbilities[o.OrganizationId].UsePolicies) && + (!orgAbilities.TryGetValue(o.OrganizationId, out var orgAbility) || orgAbility.UsePolicies) && o.PolicyEnabled && !excludedUserTypes.Contains(o.OrganizationUserType) && o.OrganizationUserStatus >= minStatus && diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs index 74833f38a0..bc2329930d 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs @@ -1,7 +1,6 @@ -using System.Text; -using System.Text.Json; -using Bit.Core.Models.Data; -using Bit.Core.Settings; +#nullable enable + +using System.Text; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using RabbitMQ.Client.Events; @@ -10,94 +9,60 @@ namespace Bit.Core.Services; public class RabbitMqEventListenerService : EventLoggingListenerService { - private IChannel _channel; - private IConnection _connection; - private readonly string _exchangeName; - private readonly ConnectionFactory _factory; - private readonly ILogger _logger; + private readonly Lazy> _lazyChannel; private readonly string _queueName; + private readonly IRabbitMqService _rabbitMqService; public RabbitMqEventListenerService( IEventMessageHandler handler, - ILogger logger, - GlobalSettings globalSettings, - string queueName) : base(handler) + string queueName, + IRabbitMqService rabbitMqService, + ILogger logger) : base(handler, logger) { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; _logger = logger; _queueName = queueName; + _rabbitMqService = rabbitMqService; + _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); } public override async Task StartAsync(CancellationToken cancellationToken) { - _connection = await _factory.CreateConnectionAsync(cancellationToken); - _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); - - await _channel.ExchangeDeclareAsync(exchange: _exchangeName, - type: ExchangeType.Fanout, - durable: true, - cancellationToken: cancellationToken); - await _channel.QueueDeclareAsync(queue: _queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _queueName, - exchange: _exchangeName, - routingKey: string.Empty, - cancellationToken: cancellationToken); + await _rabbitMqService.CreateEventQueueAsync(_queueName, cancellationToken); await base.StartAsync(cancellationToken); } protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += async (_, eventArgs) => - { - try - { - using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(eventArgs.Body.Span)); - var root = jsonDocument.RootElement; + var channel = await _lazyChannel.Value; + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, eventArgs) => { await ProcessReceivedMessageAsync(eventArgs); }; - if (root.ValueKind == JsonValueKind.Array) - { - var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); - } - else if (root.ValueKind == JsonValueKind.Object) - { - var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); + await channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken); + } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while processing the message"); - } - }; - - await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken); + internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs eventArgs) + { + await ProcessReceivedMessageAsync( + Encoding.UTF8.GetString(eventArgs.Body.Span), + eventArgs.BasicProperties.MessageId); } public override async Task StopAsync(CancellationToken cancellationToken) { - await _channel.CloseAsync(cancellationToken); - await _connection.CloseAsync(cancellationToken); + if (_lazyChannel.IsValueCreated) + { + var channel = await _lazyChannel.Value; + await channel.CloseAsync(cancellationToken); + } await base.StopAsync(cancellationToken); } public override void Dispose() { - _channel.Dispose(); - _connection.Dispose(); + if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully) + { + _lazyChannel.Value.Result.Dispose(); + } base.Dispose(); } } diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs deleted file mode 100644 index 05fcf71a92..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text.Json; -using Bit.Core.Models.Data; -using Bit.Core.Settings; -using RabbitMQ.Client; - -namespace Bit.Core.Services; -public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable -{ - private readonly ConnectionFactory _factory; - private readonly Lazy> _lazyConnection; - private readonly string _exchangeName; - - public RabbitMqEventWriteService(GlobalSettings globalSettings) - { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; - - _lazyConnection = new Lazy>(CreateConnectionAsync); - } - - public async Task CreateAsync(IEvent e) - { - var connection = await _lazyConnection.Value; - using var channel = await connection.CreateChannelAsync(); - - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); - - var body = JsonSerializer.SerializeToUtf8Bytes(e); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); - } - - public async Task CreateManyAsync(IEnumerable events) - { - var connection = await _lazyConnection.Value; - using var channel = await connection.CreateChannelAsync(); - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); - - var body = JsonSerializer.SerializeToUtf8Bytes(events); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); - } - - public async ValueTask DisposeAsync() - { - if (_lazyConnection.IsValueCreated) - { - var connection = await _lazyConnection.Value; - await connection.DisposeAsync(); - } - } - - private async Task CreateConnectionAsync() - { - return await _factory.CreateConnectionAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs index 1d6910db95..5b18d8817c 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs @@ -1,5 +1,8 @@ -using System.Text; -using Bit.Core.Settings; +#nullable enable + +using System.Text; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.Integrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RabbitMQ.Client; @@ -9,183 +12,137 @@ namespace Bit.Core.Services; public class RabbitMqIntegrationListenerService : BackgroundService { - private const string _deadLetterRoutingKey = "dead-letter"; - private IChannel _channel; - private IConnection _connection; - private readonly string _exchangeName; - private readonly string _queueName; - private readonly string _retryQueueName; - private readonly string _deadLetterQueueName; - private readonly string _routingKey; - private readonly string _retryRoutingKey; private readonly int _maxRetries; + private readonly string _queueName; + private readonly string _routingKey; + private readonly string _retryQueueName; private readonly IIntegrationHandler _handler; - private readonly ConnectionFactory _factory; + private readonly Lazy> _lazyChannel; + private readonly IRabbitMqService _rabbitMqService; private readonly ILogger _logger; - private readonly int _retryTiming; public RabbitMqIntegrationListenerService(IIntegrationHandler handler, string routingKey, string queueName, string retryQueueName, - string deadLetterQueueName, - GlobalSettings globalSettings, + int maxRetries, + IRabbitMqService rabbitMqService, ILogger logger) { _handler = handler; _routingKey = routingKey; - _retryRoutingKey = $"{_routingKey}-retry"; - _queueName = queueName; _retryQueueName = retryQueueName; - _deadLetterQueueName = deadLetterQueueName; + _queueName = queueName; + _rabbitMqService = rabbitMqService; _logger = logger; - _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; - _maxRetries = globalSettings.EventLogging.RabbitMq.MaxRetries; - _retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming; - - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; + _maxRetries = maxRetries; + _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); } public override async Task StartAsync(CancellationToken cancellationToken) { - _connection = await _factory.CreateConnectionAsync(cancellationToken); - _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); - - await _channel.ExchangeDeclareAsync(exchange: _exchangeName, - type: ExchangeType.Direct, - durable: true, - cancellationToken: cancellationToken); - - // Declare main queue - await _channel.QueueDeclareAsync(queue: _queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _queueName, - exchange: _exchangeName, - routingKey: _routingKey, - cancellationToken: cancellationToken); - - // Declare retry queue (Configurable TTL, dead-letters back to main queue) - await _channel.QueueDeclareAsync(queue: _retryQueueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary - { - { "x-dead-letter-exchange", _exchangeName }, - { "x-dead-letter-routing-key", _routingKey }, - { "x-message-ttl", _retryTiming } - }, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _retryQueueName, - exchange: _exchangeName, - routingKey: _retryRoutingKey, - cancellationToken: cancellationToken); - - // Declare dead letter queue - await _channel.QueueDeclareAsync(queue: _deadLetterQueueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _deadLetterQueueName, - exchange: _exchangeName, - routingKey: _deadLetterRoutingKey, - cancellationToken: cancellationToken); + await _rabbitMqService.CreateIntegrationQueuesAsync( + _queueName, + _retryQueueName, + _routingKey, + cancellationToken: cancellationToken); await base.StartAsync(cancellationToken); } protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - var consumer = new AsyncEventingBasicConsumer(_channel); + var channel = await _lazyChannel.Value; + var consumer = new AsyncEventingBasicConsumer(channel); consumer.ReceivedAsync += async (_, ea) => + { + await ProcessReceivedMessageAsync(ea, cancellationToken); + }; + + await channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken); + } + + internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs ea, CancellationToken cancellationToken) + { + var channel = await _lazyChannel.Value; + try { var json = Encoding.UTF8.GetString(ea.Body.Span); - try + // Determine if the message came off of the retry queue too soon + // If so, place it back on the retry queue + var integrationMessage = JsonSerializer.Deserialize(json); + if (integrationMessage is not null && + integrationMessage.DelayUntilDate.HasValue && + integrationMessage.DelayUntilDate.Value > DateTime.UtcNow) { - var result = await _handler.HandleAsync(json); - var message = result.Message; + await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea); + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + return; + } - if (result.Success) + var result = await _handler.HandleAsync(json); + var message = result.Message; + + if (result.Success) + { + // Successful integration send. Acknowledge message delivery and return + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + return; + } + + if (result.Retryable) + { + // Integration failed, but is retryable - apply delay and check max retries + message.ApplyRetry(result.DelayUntilDate); + + if (message.RetryCount < _maxRetries) { - // Successful integration send. Acknowledge message delivery and return - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); - return; - } - - if (result.Retryable) - { - // Integration failed, but is retryable - apply delay and check max retries - message.ApplyRetry(result.DelayUntilDate); - - if (message.RetryCount < _maxRetries) - { - // Publish message to the retry queue. It will be re-published for retry after a delay - await _channel.BasicPublishAsync( - exchange: _exchangeName, - routingKey: _retryRoutingKey, - body: Encoding.UTF8.GetBytes(message.ToJson()), - cancellationToken: cancellationToken); - } - else - { - // Exceeded the max number of retries; fail and send to dead letter queue - await PublishToDeadLetterAsync(message.ToJson()); - _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); - } + // Publish message to the retry queue. It will be re-published for retry after a delay + await _rabbitMqService.PublishToRetryAsync(channel, message, cancellationToken); } else { - // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries - await PublishToDeadLetterAsync(message.ToJson()); - _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + // Exceeded the max number of retries; fail and send to dead letter queue + await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); + _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); } - - // Message has been sent to retry or dead letter queues. - // Acknowledge receipt so Rabbit knows it's been processed - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); } - catch (Exception ex) + else { - // Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error - _logger.LogError(ex, "Unhandled error processing integration message."); - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries + await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); + _logger.LogWarning("Non-retryable failure. Sent to DLQ."); } - }; - await _channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken); - } - - private async Task PublishToDeadLetterAsync(string json) - { - await _channel.BasicPublishAsync( - exchange: _exchangeName, - routingKey: _deadLetterRoutingKey, - body: Encoding.UTF8.GetBytes(json)); + // Message has been sent to retry or dead letter queues. + // Acknowledge receipt so Rabbit knows it's been processed + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } + catch (Exception ex) + { + // Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error + _logger.LogError(ex, "Unhandled error processing integration message."); + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } } public override async Task StopAsync(CancellationToken cancellationToken) { - await _channel.CloseAsync(cancellationToken); - await _connection.CloseAsync(cancellationToken); + if (_lazyChannel.IsValueCreated) + { + var channel = await _lazyChannel.Value; + await channel.CloseAsync(cancellationToken); + } await base.StopAsync(cancellationToken); } public override void Dispose() { - _channel.Dispose(); - _connection.Dispose(); + if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully) + { + _lazyChannel.Value.Result.Dispose(); + } base.Dispose(); } } diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs deleted file mode 100644 index 12801e3216..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Settings; -using RabbitMQ.Client; - -namespace Bit.Core.Services; - -public class RabbitMqIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable -{ - private readonly ConnectionFactory _factory; - private readonly Lazy> _lazyConnection; - private readonly string _exchangeName; - - public RabbitMqIntegrationPublisher(GlobalSettings globalSettings) - { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; - - _lazyConnection = new Lazy>(CreateConnectionAsync); - } - - public async Task PublishAsync(IIntegrationMessage message) - { - var routingKey = message.IntegrationType.ToRoutingKey(); - var connection = await _lazyConnection.Value; - await using var channel = await connection.CreateChannelAsync(); - - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Direct, durable: true); - - var body = Encoding.UTF8.GetBytes(message.ToJson()); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: routingKey, body: body); - } - - public async ValueTask DisposeAsync() - { - if (_lazyConnection.IsValueCreated) - { - var connection = await _lazyConnection.Value; - await connection.DisposeAsync(); - } - } - - private async Task CreateConnectionAsync() - { - return await _factory.CreateConnectionAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs new file mode 100644 index 0000000000..617d1b41fb --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs @@ -0,0 +1,244 @@ +#nullable enable + +using System.Text; +using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.Enums; +using Bit.Core.Settings; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqService : IRabbitMqService +{ + private const string _deadLetterRoutingKey = "dead-letter"; + + private readonly ConnectionFactory _factory; + private readonly Lazy> _lazyConnection; + private readonly string _deadLetterQueueName; + private readonly string _eventExchangeName; + private readonly string _integrationExchangeName; + private readonly int _retryTiming; + private readonly bool _useDelayPlugin; + + public RabbitMqService(GlobalSettings globalSettings) + { + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + _deadLetterQueueName = globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName; + _eventExchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; + _integrationExchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; + _retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming; + _useDelayPlugin = globalSettings.EventLogging.RabbitMq.UseDelayPlugin; + + _lazyConnection = new Lazy>(CreateConnectionAsync); + } + + public async Task CreateChannelAsync(CancellationToken cancellationToken = default) + { + var connection = await _lazyConnection.Value; + return await connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + public async Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default) + { + using var channel = await CreateChannelAsync(cancellationToken); + await channel.QueueDeclareAsync(queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await channel.QueueBindAsync(queue: queueName, + exchange: _eventExchangeName, + routingKey: string.Empty, + cancellationToken: cancellationToken); + } + + public async Task CreateIntegrationQueuesAsync( + string queueName, + string retryQueueName, + string routingKey, + CancellationToken cancellationToken = default) + { + using var channel = await CreateChannelAsync(cancellationToken); + var retryRoutingKey = $"{routingKey}-retry"; + + // Declare main integration queue + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await channel.QueueBindAsync( + queue: queueName, + exchange: _integrationExchangeName, + routingKey: routingKey, + cancellationToken: cancellationToken); + + if (!_useDelayPlugin) + { + // Declare retry queue (Configurable TTL, dead-letters back to main queue) + // Only needed if NOT using delay plugin + await channel.QueueDeclareAsync(queue: retryQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-dead-letter-exchange", _integrationExchangeName }, + { "x-dead-letter-routing-key", routingKey }, + { "x-message-ttl", _retryTiming } + }, + cancellationToken: cancellationToken); + await channel.QueueBindAsync(queue: retryQueueName, + exchange: _integrationExchangeName, + routingKey: retryRoutingKey, + cancellationToken: cancellationToken); + } + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var routingKey = message.IntegrationType.ToRoutingKey(); + await using var channel = await CreateChannelAsync(); + + var body = Encoding.UTF8.GetBytes(message.ToJson()); + var properties = new BasicProperties + { + MessageId = message.MessageId, + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: routingKey, + body: body); + } + + public async Task PublishEventAsync(string body) + { + await using var channel = await CreateChannelAsync(); + var properties = new BasicProperties + { + MessageId = Guid.NewGuid().ToString(), + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _eventExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: string.Empty, + body: Encoding.UTF8.GetBytes(body)); + } + + public async Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken) + { + var routingKey = message.IntegrationType.ToRoutingKey(); + var retryRoutingKey = $"{routingKey}-retry"; + var properties = new BasicProperties + { + Persistent = true, + MessageId = message.MessageId, + Headers = _useDelayPlugin && message.DelayUntilDate.HasValue ? + new Dictionary + { + ["x-delay"] = Math.Max((int)(message.DelayUntilDate.Value - DateTime.UtcNow).TotalMilliseconds, 0) + } : + null + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + routingKey: _useDelayPlugin ? routingKey : retryRoutingKey, + mandatory: true, + basicProperties: properties, + body: Encoding.UTF8.GetBytes(message.ToJson()), + cancellationToken: cancellationToken); + } + + public async Task PublishToDeadLetterAsync( + IChannel channel, + IIntegrationMessage message, + CancellationToken cancellationToken) + { + var properties = new BasicProperties + { + MessageId = message.MessageId, + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: _deadLetterRoutingKey, + body: Encoding.UTF8.GetBytes(message.ToJson()), + cancellationToken: cancellationToken); + } + + public async Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs) + { + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + routingKey: eventArgs.RoutingKey, + mandatory: true, + basicProperties: new BasicProperties(eventArgs.BasicProperties), + body: eventArgs.Body); + } + + public async ValueTask DisposeAsync() + { + if (_lazyConnection.IsValueCreated) + { + var connection = await _lazyConnection.Value; + await connection.DisposeAsync(); + } + } + + private async Task CreateConnectionAsync() + { + var connection = await _factory.CreateConnectionAsync(); + using var channel = await connection.CreateChannelAsync(); + + // Declare Exchanges + await channel.ExchangeDeclareAsync(exchange: _eventExchangeName, type: ExchangeType.Fanout, durable: true); + if (_useDelayPlugin) + { + await channel.ExchangeDeclareAsync( + exchange: _integrationExchangeName, + type: "x-delayed-message", + durable: true, + arguments: new Dictionary + { + { "x-delayed-type", "direct" } + } + ); + } + else + { + await channel.ExchangeDeclareAsync(exchange: _integrationExchangeName, type: ExchangeType.Direct, durable: true); + } + + // Declare dead letter queue for Integration exchange + await channel.QueueDeclareAsync(queue: _deadLetterQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + await channel.QueueBindAsync(queue: _deadLetterQueueName, + exchange: _integrationExchangeName, + routingKey: _deadLetterRoutingKey); + + return connection; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs deleted file mode 100644 index a767776c36..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class SlackEventHandler( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository, - ISlackService slackService) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null) - { - return; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - renderedTemplate, - config.channelId - ); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs index 134e93e838..fe0f6eabe1 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +using Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/SlackService.cs index effcfdf1ce..3f82217830 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackService.cs @@ -1,4 +1,6 @@ -using System.Net.Http.Headers; +#nullable enable + +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; @@ -22,7 +24,7 @@ public class SlackService( public async Task GetChannelIdAsync(string token, string channelName) { - return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault(); + return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault() ?? string.Empty; } public async Task> GetChannelIdsAsync(string token, List channelNames) @@ -58,7 +60,7 @@ public class SlackService( } else { - logger.LogError("Error getting Channel Ids: {Error}", result.Error); + logger.LogError("Error getting Channel Ids: {Error}", result?.Error ?? "Unknown Error"); nextCursor = string.Empty; } @@ -89,7 +91,7 @@ public class SlackService( new KeyValuePair("redirect_uri", redirectUrl) })); - SlackOAuthResponse result; + SlackOAuthResponse? result; try { result = await tokenResponse.Content.ReadFromJsonAsync(); @@ -99,7 +101,7 @@ public class SlackService( result = null; } - if (result == null) + if (result is null) { logger.LogError("Error obtaining token via OAuth: Unknown error"); return string.Empty; @@ -130,6 +132,11 @@ public class SlackService( var response = await _httpClient.SendAsync(request); var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + logger.LogError("Error retrieving Slack user ID: Unknown error"); + return string.Empty; + } if (!result.Ok) { logger.LogError("Error retrieving Slack user ID: {Error}", result.Error); @@ -151,6 +158,11 @@ public class SlackService( var response = await _httpClient.SendAsync(request); var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + logger.LogError("Error opening DM channel: Unknown error"); + return string.Empty; + } if (!result.Ok) { logger.LogError("Error opening DM channel: {Error}", result.Error); diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs deleted file mode 100644 index 97453497bc..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class WebhookEventHandler( - IHttpClientFactory httpClientFactory, - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - - public const string HttpClientName = "WebhookEventHandlerHttpClient"; - - protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - return; - } - - var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(config.url, content); - response.EnsureSuccessStatusCode(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs index 5f9898afe8..df364b2a48 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs @@ -1,4 +1,6 @@ -using System.Globalization; +#nullable enable + +using System.Globalization; using System.Net; using System.Text; using Bit.Core.AdminConsole.Models.Data.Integrations; @@ -29,7 +31,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) case HttpStatusCode.ServiceUnavailable: case HttpStatusCode.GatewayTimeout: result.Retryable = true; - result.FailureReason = response.ReasonPhrase; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; if (response.Headers.TryGetValues("Retry-After", out var values)) { @@ -52,7 +54,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) break; default: result.Retryable = false; - result.FailureReason = response.ReasonPhrase; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; break; } diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs index 718e44ae5f..2f8481cea2 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs @@ -43,7 +43,7 @@ public class EmailTwoFactorTokenProvider : EmailTokenProvider private static bool HasProperMetaData(TwoFactorProvider provider) { - return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") && - !string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]); + return provider?.MetaData != null && provider.MetaData.TryGetValue("Email", out var emailValue) && + !string.IsNullOrWhiteSpace((string)emailValue); } } diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 0bf75d0fc3..3b4b0fa520 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -80,7 +80,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); var keys = LoadKeys(provider); - if (!provider.MetaData.TryGetValue("login", out var value)) + if (!provider.MetaData.TryGetValue("login", out var login)) { return false; } @@ -88,7 +88,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var clientResponse = JsonSerializer.Deserialize(token, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - var jsonOptions = value.ToString(); + var jsonOptions = login.ToString(); var options = AssertionOptions.FromJson(jsonOptions); var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id)); @@ -148,9 +148,9 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider for (var i = 1; i <= 5; i++) { var keyName = $"Key{i}"; - if (provider.MetaData.ContainsKey(keyName)) + if (provider.MetaData.TryGetValue(keyName, out var value)) { - var key = new TwoFactorProvider.WebAuthnData((dynamic)provider.MetaData[keyName]); + var key = new TwoFactorProvider.WebAuthnData((dynamic)value); keys.Add(new Tuple(keyName, key)); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index e721649dc9..289bbff7f8 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -11,9 +10,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -26,15 +22,12 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _providerServiceDataProtector; - private readonly ICurrentContext _currentContext; - private readonly IUserService _userService; private readonly IMailService _mailService; @@ -48,11 +41,9 @@ public class RegisterUserCommand : IRegisterUserCommand IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - IReferenceEventService referenceEventService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - ICurrentContext currentContext, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, @@ -62,14 +53,12 @@ public class RegisterUserCommand : IRegisterUserCommand _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _referenceEventService = referenceEventService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; - _currentContext = currentContext; _userService = userService; _mailService = mailService; @@ -86,7 +75,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -119,12 +107,6 @@ public class RegisterUserCommand : IRegisterUserCommand sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - SignupInitiationPath = initiationPath - }); - return result; } } @@ -134,8 +116,6 @@ public class RegisterUserCommand : IRegisterUserCommand { await _mailService.SendWelcomeEmailAsync(user); } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -263,10 +243,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails - }); } return result; @@ -285,7 +261,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -306,7 +281,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -325,7 +299,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 28f4dea4b2..0cffad72d3 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -96,6 +96,12 @@ public static class StripeConstants public const string Reverse = "reverse"; } + public static class TaxIdType + { + public const string EUVAT = "eu_vat"; + public const string SpanishNIF = "es_cif"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Models/OrganizationMetadata.cs index 41666949bf..0f2bf9a454 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Models/OrganizationMetadata.cs @@ -10,7 +10,8 @@ public record OrganizationMetadata( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadata Default => new OrganizationMetadata( false, @@ -22,5 +23,6 @@ public record OrganizationMetadata( false, null, null, - null); + null, + 0); } diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs index f719fd1e87..45a48c3f80 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -31,6 +31,7 @@ public record PlanAdapter : Plan HasScim = HasFeature("scim"); HasResetPassword = HasFeature("resetPassword"); UsersGetPremium = HasFeature("usersGetPremium"); + HasCustomPermissions = HasFeature("customPermissions"); UpgradeSortOrder = plan.AdditionalData.TryGetValue("upgradeSortOrder", out var upgradeSortOrder) ? int.Parse(upgradeSortOrder) : 0; @@ -141,6 +142,7 @@ public record PlanAdapter : Plan var stripeSeatPlanId = GetStripeSeatPlanId(seats); var hasAdditionalSeatsOption = seats.IsScalable; var seatPrice = GetSeatPrice(seats); + var baseSeats = GetBaseSeats(seats); var maxSeats = GetMaxSeats(seats); var allowSeatAutoscale = seats.IsScalable; var maxProjects = plan.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; @@ -156,6 +158,7 @@ public record PlanAdapter : Plan StripeSeatPlanId = stripeSeatPlanId, HasAdditionalSeatsOption = hasAdditionalSeatsOption, SeatPrice = seatPrice, + BaseSeats = baseSeats, MaxSeats = maxSeats, AllowSeatAutoscale = allowSeatAutoscale, MaxProjects = maxProjects @@ -168,8 +171,16 @@ public record PlanAdapter : Plan private static decimal GetBasePrice(PurchasableDTO purchasable) => purchasable.FromPackaged(x => x.Price); + private static int GetBaseSeats(FreeOrScalableDTO freeOrScalable) + => freeOrScalable.Match( + free => free.Quantity, + scalable => scalable.Provided); + private static int GetBaseSeats(PurchasableDTO purchasable) - => purchasable.FromPackaged(x => x.Quantity); + => purchasable.Match( + free => free.Quantity, + packaged => packaged.Quantity, + scalable => scalable.Provided); private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) => freeOrScalable.Match( diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 95df34dfd4..32521f00c8 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -77,13 +77,14 @@ public class OrganizationBillingService( var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); var isManaged = organization.Status == OrganizationStatusType.Managed; - + var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return OrganizationMetadata.Default with { IsEligibleForSelfHost = isEligibleForSelfHost, - IsManaged = isManaged + IsManaged = isManaged, + OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } @@ -117,7 +118,8 @@ public class OrganizationBillingService( subscription.Status == StripeConstants.SubscriptionStatus.Canceled, invoice?.DueDate, invoice?.Created, - subscription.CurrentPeriodEnd); + subscription.CurrentPeriodEnd, + orgOccupiedSeats.Total); } public async Task @@ -244,12 +246,23 @@ public class OrganizationBillingService( organization.Id, customerSetup.TaxInformation.Country, customerSetup.TaxInformation.TaxId); + + throw new BadRequestException("billingTaxIdTypeInferenceError"); } customerCreateOptions.TaxIdData = [ new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + customerCreateOptions.TaxIdData.Add(new CustomerTaxIdDataOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{customerSetup.TaxInformation.TaxId}" + }); + } } var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; @@ -416,7 +429,7 @@ public class OrganizationBillingService( var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - if (setNonUSBusinessUseToReverseCharge) + if (setNonUSBusinessUseToReverseCharge && customer.HasBillingLocation()) { subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 75a1bf76ec..796f700e9f 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -648,6 +648,12 @@ public class SubscriberService( { await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" }); + } } catch (StripeException e) { diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs index 304abbaae0..c777d0c0d1 100644 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -80,6 +80,15 @@ public class PreviewTaxAmountCommand( Value = taxInformation.TaxId } ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{parameters.TaxInformation.TaxId}" + }); + } } if (planType.GetProductTier() == ProductTierType.Families) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7a2b3c9ac7..49d360ec50 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -23,6 +23,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; public const string SSHKeyCipherMinimumVersion = "2024.12.0"; + public const string DenyLegacyUserMinimumVersion = "2025.6.0"; /// /// Used by IdentityServer to identify our own provider. @@ -109,6 +110,8 @@ public static class FeatureFlagKeys public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; + public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript"; + public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; @@ -142,9 +145,7 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; - public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; @@ -180,6 +181,8 @@ public static class FeatureFlagKeys public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string MobileErrorReporting = "mobile-error-reporting"; public const string AndroidChromeAutofill = "android-chrome-autofill"; + public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; + public const string AppIntents = "app-intents"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; @@ -200,7 +203,6 @@ public static class FeatureFlagKeys public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; - public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string PhishingDetection = "phishing-detection"; public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index cbd90055b0..68d4606907 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -64,39 +64,39 @@ public class CurrentContext : ICurrentContext HttpContext = httpContext; await BuildAsync(httpContext.User, globalSettings); - if (DeviceIdentifier == null && httpContext.Request.Headers.ContainsKey("Device-Identifier")) + if (DeviceIdentifier == null && httpContext.Request.Headers.TryGetValue("Device-Identifier", out var deviceIdentifier)) { - DeviceIdentifier = httpContext.Request.Headers["Device-Identifier"]; + DeviceIdentifier = deviceIdentifier; } - if (httpContext.Request.Headers.ContainsKey("Device-Type") && - Enum.TryParse(httpContext.Request.Headers["Device-Type"].ToString(), out DeviceType dType)) + if (httpContext.Request.Headers.TryGetValue("Device-Type", out var deviceType) && + Enum.TryParse(deviceType.ToString(), out DeviceType dType)) { DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.ContainsKey("X-Cf-Bot-Score") && - int.TryParse(httpContext.Request.Headers["X-Cf-Bot-Score"], out var parsedBotScore)) + if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) && + int.TryParse(cfBotScore, out var parsedBotScore)) { BotScore = parsedBotScore; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Worked-Proxied")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) { - CloudflareWorkerProxied = httpContext.Request.Headers["X-Cf-Worked-Proxied"] == "1"; + CloudflareWorkerProxied = cfWorkedProxied == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Is-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) { - IsBot = httpContext.Request.Headers["X-Cf-Is-Bot"] == "1"; + IsBot = cfIsBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Maybe-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) { - MaybeBot = httpContext.Request.Headers["X-Cf-Maybe-Bot"] == "1"; + MaybeBot = cfMaybeBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("Bitwarden-Client-Version") && Version.TryParse(httpContext.Request.Headers["Bitwarden-Client-Version"], out var cVersion)) + if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; } @@ -190,14 +190,14 @@ public class CurrentContext : ICurrentContext private List GetOrganizations(Dictionary> claimsDict, bool orgApi) { - var accessSecretsManager = claimsDict.ContainsKey(Claims.SecretsManagerAccess) - ? claimsDict[Claims.SecretsManagerAccess].ToDictionary(s => s.Value, _ => true) + var accessSecretsManager = claimsDict.TryGetValue(Claims.SecretsManagerAccess, out var secretsManagerAccessClaim) + ? secretsManagerAccessClaim.ToDictionary(s => s.Value, _ => true) : new Dictionary(); var organizations = new List(); - if (claimsDict.ContainsKey(Claims.OrganizationOwner)) + if (claimsDict.TryGetValue(Claims.OrganizationOwner, out var organizationOwnerClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationOwner].Select(c => + organizations.AddRange(organizationOwnerClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -214,9 +214,9 @@ public class CurrentContext : ICurrentContext }); } - if (claimsDict.ContainsKey(Claims.OrganizationAdmin)) + if (claimsDict.TryGetValue(Claims.OrganizationAdmin, out var organizationAdminClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationAdmin].Select(c => + organizations.AddRange(organizationAdminClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -225,9 +225,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationUser)) + if (claimsDict.TryGetValue(Claims.OrganizationUser, out var organizationUserClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationUser].Select(c => + organizations.AddRange(organizationUserClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -236,9 +236,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationCustom)) + if (claimsDict.TryGetValue(Claims.OrganizationCustom, out var organizationCustomClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationCustom].Select(c => + organizations.AddRange(organizationCustomClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -254,9 +254,9 @@ public class CurrentContext : ICurrentContext private List GetProviders(Dictionary> claimsDict) { var providers = new List(); - if (claimsDict.ContainsKey(Claims.ProviderAdmin)) + if (claimsDict.TryGetValue(Claims.ProviderAdmin, out var providerAdminClaim)) { - providers.AddRange(claimsDict[Claims.ProviderAdmin].Select(c => + providers.AddRange(providerAdminClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -264,9 +264,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.ProviderServiceUser)) + if (claimsDict.TryGetValue(Claims.ProviderServiceUser, out var providerServiceUserClaim)) { - providers.AddRange(claimsDict[Claims.ProviderServiceUser].Select(c => + providers.AddRange(providerServiceUserClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -504,20 +504,20 @@ public class CurrentContext : ICurrentContext private string GetClaimValue(Dictionary> claims, string type) { - if (!claims.ContainsKey(type)) + if (!claims.TryGetValue(type, out var claim)) { return null; } - return claims[type].FirstOrDefault()?.Value; + return claim.FirstOrDefault()?.Value; } private Permissions SetOrganizationPermissionsFromClaims(string organizationId, Dictionary> claimsDict) { bool hasClaim(string claimKey) { - return claimsDict.ContainsKey(claimKey) ? - claimsDict[claimKey].Any(x => x.Value == organizationId) : false; + return claimsDict.TryGetValue(claimKey, out var claim) ? + claim.Any(x => x.Value == organizationId) : false; } return new Permissions diff --git a/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs b/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs index 9d89edf633..db605d6b74 100644 --- a/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs +++ b/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs @@ -1,9 +1,9 @@ -using Bit.Core.Entities; +#nullable enable + +using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Tools.Entities; +namespace Bit.Core.Dirt.Reports.Entities; public class PasswordHealthReportApplication : ITableObject, IRevisable { diff --git a/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs index 943d56c53e..759337d5cf 100644 --- a/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs +++ b/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Tools.Models.Data; +namespace Bit.Core.Dirt.Reports.Models.Data; public class MemberAccessDetails { @@ -30,13 +30,13 @@ public class MemberAccessCipherDetails public bool UsesKeyConnector { get; set; } /// - /// The details for the member's collection access depending - /// on the collections and groups they are assigned to + /// The details for the member's collection access depending + /// on the collections and groups they are assigned to /// public IEnumerable AccessDetails { get; set; } /// - /// A distinct list of the cipher ids associated with + /// A distinct list of the cipher ids associated with /// the organization member /// public IEnumerable CipherIds { get; set; } diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs index b191799ba0..f8232ffa92 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs @@ -1,11 +1,11 @@ -using Bit.Core.Exceptions; +using Bit.Core.Dirt.Reports.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Repositories; +using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; -using Bit.Core.Tools.Repositories; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs index 73a8f84e6a..55914dca37 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; -using Bit.Core.Tools.Repositories; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Repositories; +using Bit.Core.Exceptions; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class DropPasswordHealthReportApplicationCommand : IDropPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs index 5baf5b2f72..d9b5e79a0c 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.Repositories; +using Bit.Core.Dirt.Reports.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Repositories; +using Bit.Core.Exceptions; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs index 9d145a79b6..0a4aa29f2f 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IAddPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs index 0adf09cab8..8e97e32ac7 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IDropPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs index f24119c2b7..ae2f759756 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Tools.Entities; +using Bit.Core.Dirt.Reports.Entities; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IGetPasswordHealthReportApplicationQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs index 0c165a7dc2..4a8039e6bc 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs @@ -2,21 +2,21 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs index c55495fd13..98ed780db3 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs @@ -1,7 +1,7 @@ -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; public interface IMemberAccessCipherDetailsQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index 4970f0515b..d847c8051e 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public static class ReportingServiceCollectionExtensions { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs index dfc544b1c3..c4e646fcd7 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class AddPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs index 1464e68f04..544b9a51d5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class DropPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs index 395230f430..b40dfc6dec 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class MemberAccessCipherDetailsRequest { diff --git a/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs b/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs index 374f12e122..5b57932868 100644 --- a/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs +++ b/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; +using Bit.Core.Dirt.Reports.Entities; +using Bit.Core.Repositories; -namespace Bit.Core.Tools.Repositories; +namespace Bit.Core.Dirt.Reports.Repositories; public interface IPasswordHealthReportApplicationRepository : IRepository { diff --git a/src/Core/Entities/Collection.cs b/src/Core/Entities/Collection.cs index 8babe10e4c..275cd80d2f 100644 --- a/src/Core/Entities/Collection.cs +++ b/src/Core/Entities/Collection.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; using Bit.Core.Utilities; #nullable enable @@ -14,6 +15,8 @@ public class Collection : ITableObject public string? ExternalId { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public CollectionType Type { get; set; } = CollectionType.SharedCollection; + public string? DefaultUserCollectionEmail { get; set; } public void SetNewId() { diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 08981ca2d3..b92d22b0e3 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; @@ -11,7 +10,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.Entities; -public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable +public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser { private Dictionary? _twoFactorProviders; @@ -196,12 +195,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.TryGetValue(provider, out var value)) - { - return null; - } - - return value; + return providers?.GetValueOrDefault(provider); } public long StorageBytesRemaining() diff --git a/src/Core/Enums/CollectionType.cs b/src/Core/Enums/CollectionType.cs new file mode 100644 index 0000000000..9bc4fcc9c2 --- /dev/null +++ b/src/Core/Enums/CollectionType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Enums; + +public enum CollectionType +{ + SharedCollection = 0, + DefaultUserCollection = 1, +} diff --git a/src/Core/Exceptions/BadRequestException.cs b/src/Core/Exceptions/BadRequestException.cs index 042f853a57..b27bc7510f 100644 --- a/src/Core/Exceptions/BadRequestException.cs +++ b/src/Core/Exceptions/BadRequestException.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Core.Exceptions; +#nullable enable + public class BadRequestException : Exception { public BadRequestException() : base() @@ -41,5 +43,5 @@ public class BadRequestException : Exception } } - public ModelStateDictionary ModelState { get; set; } + public ModelStateDictionary? ModelState { get; set; } } diff --git a/src/Core/Exceptions/ConflictException.cs b/src/Core/Exceptions/ConflictException.cs index 27b90a657f..92fcc52d7f 100644 --- a/src/Core/Exceptions/ConflictException.cs +++ b/src/Core/Exceptions/ConflictException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class ConflictException : Exception { public ConflictException() : base("Conflict.") { } diff --git a/src/Core/Exceptions/DnsQueryException.cs b/src/Core/Exceptions/DnsQueryException.cs index 57b2c56daa..e3f605dec4 100644 --- a/src/Core/Exceptions/DnsQueryException.cs +++ b/src/Core/Exceptions/DnsQueryException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DnsQueryException : Exception { public DnsQueryException(string message) diff --git a/src/Core/Exceptions/DomainClaimedException.cs b/src/Core/Exceptions/DomainClaimedException.cs index 09ccb3d0d8..9ac6972fa1 100644 --- a/src/Core/Exceptions/DomainClaimedException.cs +++ b/src/Core/Exceptions/DomainClaimedException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DomainClaimedException : Exception { public DomainClaimedException() diff --git a/src/Core/Exceptions/DomainVerifiedException.cs b/src/Core/Exceptions/DomainVerifiedException.cs index d3a3fd4de4..1fb704bd55 100644 --- a/src/Core/Exceptions/DomainVerifiedException.cs +++ b/src/Core/Exceptions/DomainVerifiedException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DomainVerifiedException : Exception { public DomainVerifiedException() diff --git a/src/Core/Exceptions/DuplicateDomainException.cs b/src/Core/Exceptions/DuplicateDomainException.cs index 8d347dda55..4f61f333f5 100644 --- a/src/Core/Exceptions/DuplicateDomainException.cs +++ b/src/Core/Exceptions/DuplicateDomainException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DuplicateDomainException : Exception { public DuplicateDomainException() diff --git a/src/Core/Exceptions/FeatureUnavailableException.cs b/src/Core/Exceptions/FeatureUnavailableException.cs index 7bea350956..80fd7d0635 100644 --- a/src/Core/Exceptions/FeatureUnavailableException.cs +++ b/src/Core/Exceptions/FeatureUnavailableException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + /// /// Exception to throw when a requested feature is not yet enabled/available for the requesting context. /// diff --git a/src/Core/Exceptions/GatewayException.cs b/src/Core/Exceptions/GatewayException.cs index 73e8cd7613..4b24c8d107 100644 --- a/src/Core/Exceptions/GatewayException.cs +++ b/src/Core/Exceptions/GatewayException.cs @@ -1,8 +1,10 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class GatewayException : Exception { - public GatewayException(string message, Exception innerException = null) + public GatewayException(string message, Exception? innerException = null) : base(message, innerException) { } } diff --git a/src/Core/Exceptions/InvalidEmailException.cs b/src/Core/Exceptions/InvalidEmailException.cs index 1f17acf62e..c38ec0ac38 100644 --- a/src/Core/Exceptions/InvalidEmailException.cs +++ b/src/Core/Exceptions/InvalidEmailException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class InvalidEmailException : Exception { public InvalidEmailException() diff --git a/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs b/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs index cfc7c56c1c..6ec15da308 100644 --- a/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs +++ b/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class InvalidGatewayCustomerIdException : Exception { public InvalidGatewayCustomerIdException() diff --git a/src/Core/Exceptions/NotFoundException.cs b/src/Core/Exceptions/NotFoundException.cs index 70769d41ed..6a61e35868 100644 --- a/src/Core/Exceptions/NotFoundException.cs +++ b/src/Core/Exceptions/NotFoundException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class NotFoundException : Exception { public NotFoundException() : base() diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index 9021782d20..a699a26fcc 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -10,9 +10,11 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.HostedServices; +#nullable enable + public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService _applicationCacheService; + private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -20,8 +22,8 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable private readonly ServiceBusAdministrationClient _serviceBusAdministrationClient; private readonly string _subName; private readonly string _topicName; - private CancellationTokenSource _cts; - private Task _executingTask; + private CancellationTokenSource? _cts; + private Task? _executingTask; public ApplicationCacheHostedService( @@ -67,13 +69,17 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable { await _subscriptionReceiver.CloseAsync(cancellationToken); await _serviceBusClient.DisposeAsync(); - _cts.Cancel(); + _cts?.Cancel(); try { await _serviceBusAdministrationClient.DeleteSubscriptionAsync(_topicName, _subName, cancellationToken); } catch { } - await _executingTask; + + if (_executingTask != null) + { + await _executingTask; + } } public virtual void Dispose() diff --git a/src/Core/HostedServices/IpRateLimitSeedStartupService.cs b/src/Core/HostedServices/IpRateLimitSeedStartupService.cs index a6869d929c..827dd94806 100644 --- a/src/Core/HostedServices/IpRateLimitSeedStartupService.cs +++ b/src/Core/HostedServices/IpRateLimitSeedStartupService.cs @@ -3,6 +3,8 @@ using Microsoft.Extensions.Hosting; namespace Bit.Core.HostedServices; +#nullable enable + /// /// A startup service that will seed the IP rate limiting stores with any values in the /// GlobalSettings configuration. diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/IdentityServer/DistributedCacheCookieManager.cs index 9771b40662..5d6717ac41 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/IdentityServer/DistributedCacheCookieManager.cs @@ -63,6 +63,6 @@ public class DistributedCacheCookieManager : ICookieManager private string GetKey(string key, string id) => $"{CacheKeyPrefix}-{key}-{id}"; private string GetId(HttpContext context, string key) => - context.Request.Cookies.ContainsKey(key) ? - context.Request.Cookies[key] : null; + context.Request.Cookies.TryGetValue(key, out var cookie) ? + cookie : null; } diff --git a/src/Core/Jobs/BaseJob.cs b/src/Core/Jobs/BaseJob.cs index 56c39014a7..a56045f659 100644 --- a/src/Core/Jobs/BaseJob.cs +++ b/src/Core/Jobs/BaseJob.cs @@ -3,6 +3,8 @@ using Quartz; namespace Bit.Core.Jobs; +#nullable enable + public abstract class BaseJob : IJob { protected readonly ILogger _logger; diff --git a/src/Core/Jobs/BaseJobsHostedService.cs b/src/Core/Jobs/BaseJobsHostedService.cs index 897a382a2b..2ade53c6bb 100644 --- a/src/Core/Jobs/BaseJobsHostedService.cs +++ b/src/Core/Jobs/BaseJobsHostedService.cs @@ -8,6 +8,8 @@ using Quartz.Impl.Matchers; namespace Bit.Core.Jobs; +#nullable enable + public abstract class BaseJobsHostedService : IHostedService, IDisposable { private const int MaximumJobRetries = 10; @@ -16,7 +18,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable private readonly ILogger _listenerLogger; protected readonly ILogger _logger; - private IScheduler _scheduler; + private IScheduler? _scheduler; protected GlobalSettings _globalSettings; public BaseJobsHostedService( @@ -31,7 +33,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _globalSettings = globalSettings; } - public IEnumerable> Jobs { get; protected set; } + public IEnumerable>? Jobs { get; protected set; } public virtual async Task StartAsync(CancellationToken cancellationToken) { @@ -61,10 +63,19 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _scheduler.ListenerManager.AddJobListener(new JobListener(_listenerLogger), GroupMatcher.AnyGroup()); await _scheduler.Start(cancellationToken); + + var jobKeys = new List(); + var triggerKeys = new List(); + if (Jobs != null) { foreach (var (job, trigger) in Jobs) { + jobKeys.Add(JobBuilder.Create(job) + .WithIdentity(job.FullName!) + .Build().Key); + triggerKeys.Add(trigger.Key); + for (var retry = 0; retry < MaximumJobRetries; retry++) { // There's a race condition when starting multiple containers simultaneously, retry until it succeeds.. @@ -77,7 +88,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable } var jobDetail = JobBuilder.Create(job) - .WithIdentity(job.FullName) + .WithIdentity(job.FullName!) .Build(); var dupeJ = await _scheduler.GetJobDetail(jobDetail.Key); @@ -106,13 +117,6 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable // Delete old Jobs and Triggers var existingJobKeys = await _scheduler.GetJobKeys(GroupMatcher.AnyGroup()); - var jobKeys = Jobs.Select(j => - { - var job = j.Item1; - return JobBuilder.Create(job) - .WithIdentity(job.FullName) - .Build().Key; - }); foreach (var key in existingJobKeys) { @@ -126,7 +130,6 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable } var existingTriggerKeys = await _scheduler.GetTriggerKeys(GroupMatcher.AnyGroup()); - var triggerKeys = Jobs.Select(j => j.Item2.Key); foreach (var key in existingTriggerKeys) { @@ -142,7 +145,10 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable public virtual async Task StopAsync(CancellationToken cancellationToken) { - await _scheduler?.Shutdown(cancellationToken); + if (_scheduler is not null) + { + await _scheduler.Shutdown(cancellationToken); + } } public virtual void Dispose() diff --git a/src/Core/Jobs/JobFactory.cs b/src/Core/Jobs/JobFactory.cs index 6529443d97..8289a90322 100644 --- a/src/Core/Jobs/JobFactory.cs +++ b/src/Core/Jobs/JobFactory.cs @@ -4,6 +4,8 @@ using Quartz.Spi; namespace Bit.Core.Jobs; +#nullable enable + public class JobFactory : IJobFactory { private readonly IServiceProvider _container; @@ -16,7 +18,7 @@ public class JobFactory : IJobFactory public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { var scope = _container.CreateScope(); - return scope.ServiceProvider.GetService(bundle.JobDetail.JobType) as IJob; + return (scope.ServiceProvider.GetService(bundle.JobDetail.JobType) as IJob)!; } public void ReturnJob(IJob job) diff --git a/src/Core/Jobs/JobListener.cs b/src/Core/Jobs/JobListener.cs index e5e05e4b6b..0dc865655d 100644 --- a/src/Core/Jobs/JobListener.cs +++ b/src/Core/Jobs/JobListener.cs @@ -3,6 +3,8 @@ using Quartz; namespace Bit.Core.Jobs; +#nullable enable + public class JobListener : IJobListener { private readonly ILogger _logger; @@ -28,7 +30,7 @@ public class JobListener : IJobListener return Task.FromResult(0); } - public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException jobException, + public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default(CancellationToken)) { _logger.LogInformation(Constants.BypassFiltersEventId, null, "Finished job {0} at {1}.", diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs index 7ed9fb7d1a..bcf6be62c9 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs @@ -148,7 +148,7 @@ - + diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs index bf4ec50796..72f669bf34 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs @@ -2,7 +2,7 @@ ---------------------------- -- Twitter: https://twitter.com/bitwarden +- X: https://x.com/bitwarden - Reddit: https://www.reddit.com/r/Bitwarden/ - Community Forums: https://community.bitwarden.com/ - GitHub: https://github.com/bitwarden diff --git a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs index f5772d61f6..f79e5f7043 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs @@ -177,7 +177,7 @@
TwitterX Reddit CommunityForums GitHub