1
0
mirror of https://github.com/bitwarden/server synced 2026-03-02 19:31:24 +00:00

Merge branch 'main' into PM-29660-continuationtokens

This commit is contained in:
cd-bitwarden
2025-12-26 12:32:21 -05:00
133 changed files with 2552 additions and 1441 deletions

7
.github/CODEOWNERS vendored
View File

@@ -53,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
# Dirt (Data Insights & Reporting) team
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
src/Events @bitwarden/team-data-insights-and-reporting-dev
src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev
test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev
test/Events.Test @bitwarden/team-data-insights-and-reporting-dev
test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev
# Vault team
**/Vault @bitwarden/team-vault-dev
@@ -63,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
src/Events @bitwarden/team-admin-console-dev
src/EventsProcessor @bitwarden/team-admin-console-dev
# Billing team
**/*billing* @bitwarden/team-billing-dev

View File

@@ -38,7 +38,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
@@ -68,7 +68,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -169,10 +169,10 @@ jobs:
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
########## ACRs ##########
- name: Log in to Azure
@@ -246,7 +246,7 @@ jobs:
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -264,7 +264,7 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
@@ -289,7 +289,7 @@ jobs:
actions: read
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -416,7 +416,7 @@ jobs:
- win-x64
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -481,7 +481,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -531,7 +531,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -59,7 +59,7 @@ jobs:
- name: Collect
id: collect
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
uses: launchdarkly/find-code-references@89a7d362d1d4b3725fe0fe0ccd0dc69e3bdcba58 # v2.14.0
with:
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
projKey: default

View File

@@ -87,7 +87,7 @@ jobs:
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -95,7 +95,7 @@ jobs:
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
- name: Run k6 tests
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d # v1.3.1
continue-on-error: false
env:
K6_OTEL_METRIC_PREFIX: k6_

View File

@@ -31,7 +31,7 @@ jobs:
label: "DB-migrations-changed"
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
persist-credentials: false

View File

@@ -91,7 +91,6 @@ jobs:
- project_name: Nginx
- project_name: Notifications
- project_name: Scim
- project_name: Server
- project_name: Setup
- project_name: Sso
steps:
@@ -106,7 +105,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -39,7 +39,7 @@ jobs:
fi
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -89,7 +89,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-EU.zip,

View File

@@ -83,7 +83,7 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -91,7 +91,7 @@ jobs:
permission-contents: write
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
@@ -207,7 +207,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -215,7 +215,7 @@ jobs:
permission-contents: write
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}

View File

@@ -44,7 +44,7 @@ jobs:
checks: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -156,7 +156,7 @@ jobs:
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -165,7 +165,7 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Docker Compose down
if: always()
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -269,7 +269,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -40,7 +40,7 @@ jobs:
toolchain: stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
- name: Print environment
run: |
@@ -59,7 +59,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -68,4 +68,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2

View File

@@ -58,6 +58,42 @@ Invoke-RestMethod -OutFile bitwarden.ps1 `
.\bitwarden.ps1 -start
```
## Production Container Images
<details>
<summary><b>View Current Production Image Hashes</b> (click to expand)</summary>
<br>
### US Production Cluster
| Service | Image Hash |
|---------|------------|
| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
### EU Production Cluster
| Service | Image Hash |
|---------|------------|
| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
</details>
## We're Hiring!
Interested in contributing in a big way? Consider joining our team! We're hiring for many positions. Please take a look at our [Careers page](https://bitwarden.com/careers/) to see what opportunities are currently open as well as what it's like to work at Bitwarden.

View File

@@ -57,8 +57,7 @@ public class ProviderClientsController(
Owner = user,
BillingEmail = provider.BillingEmail,
OwnerKey = requestBody.Key,
PublicKey = requestBody.KeyPair.PublicKey,
PrivateKey = requestBody.KeyPair.EncryptedPrivateKey,
Keys = requestBody.KeyPair.ToPublicKeyEncryptionKeyPairData(),
CollectionName = requestBody.CollectionName,
IsFromProvider = true
};

View File

@@ -113,11 +113,10 @@ public class OrganizationCreateRequestModel : IValidatableObject
BillingAddressCountry = BillingAddressCountry,
},
InitiationPath = InitiationPath,
SkipTrial = SkipTrial
SkipTrial = SkipTrial,
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};
Keys?.ToOrganizationSignup(orgSignup);
return orgSignup;
}

View File

@@ -2,8 +2,7 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Business;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
@@ -14,48 +13,10 @@ public class OrganizationKeysRequestModel
[Required]
public string EncryptedPrivateKey { get; set; }
public OrganizationSignup ToOrganizationSignup(OrganizationSignup existingSignup)
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
{
if (string.IsNullOrWhiteSpace(existingSignup.PublicKey))
{
existingSignup.PublicKey = PublicKey;
}
if (string.IsNullOrWhiteSpace(existingSignup.PrivateKey))
{
existingSignup.PrivateKey = EncryptedPrivateKey;
}
return existingSignup;
}
public OrganizationUpgrade ToOrganizationUpgrade(OrganizationUpgrade existingUpgrade)
{
if (string.IsNullOrWhiteSpace(existingUpgrade.PublicKey))
{
existingUpgrade.PublicKey = PublicKey;
}
if (string.IsNullOrWhiteSpace(existingUpgrade.PrivateKey))
{
existingUpgrade.PrivateKey = EncryptedPrivateKey;
}
return existingUpgrade;
}
public Organization ToOrganization(Organization existingOrg)
{
if (string.IsNullOrWhiteSpace(existingOrg.PublicKey))
{
existingOrg.PublicKey = PublicKey;
}
if (string.IsNullOrWhiteSpace(existingOrg.PrivateKey))
{
existingOrg.PrivateKey = EncryptedPrivateKey;
}
return existingOrg;
return new PublicKeyEncryptionKeyPairData(
wrappedPrivateKey: EncryptedPrivateKey,
publicKey: PublicKey);
}
}

View File

@@ -110,10 +110,9 @@ public class OrganizationNoPaymentCreateRequest
BillingAddressCountry = BillingAddressCountry,
},
InitiationPath = InitiationPath,
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};
Keys?.ToOrganizationSignup(orgSignup);
return orgSignup;
}
}

View File

@@ -22,7 +22,6 @@ public class OrganizationUpdateRequestModel
OrganizationId = organizationId,
Name = Name,
BillingEmail = BillingEmail,
PublicKey = Keys?.PublicKey,
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};
}

View File

@@ -43,11 +43,10 @@ public class OrganizationUpgradeRequestModel
{
BillingAddressCountry = BillingAddressCountry,
BillingAddressPostalCode = BillingAddressPostalCode
}
},
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
};
Keys?.ToOrganizationUpgrade(orgUpgrade);
return orgUpgrade;
}
}

View File

@@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

View File

@@ -0,0 +1,91 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api.OrganizationLicenses;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("licenses")]
[Authorize("Licensing")]
[SelfHosted(NotSelfHostedOnly = true)]
public class LicensesController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
private readonly ICurrentContext _currentContext;
public LicensesController(
IUserRepository userRepository,
IUserService userService,
IOrganizationRepository organizationRepository,
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
ICurrentContext currentContext)
{
_userRepository = userRepository;
_userService = userService;
_organizationRepository = organizationRepository;
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
_currentContext = currentContext;
}
[HttpGet("user/{id}")]
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
{
var user = await _userRepository.GetByIdAsync(new Guid(id));
if (user == null)
{
return null;
}
else if (!user.LicenseKey.Equals(key))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
var license = await _userService.GenerateLicenseAsync(user, null);
return license;
}
/// <summary>
/// Used by self-hosted installations to get an updated license file
/// </summary>
[HttpGet("organization/{id}")]
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
if (organization == null)
{
throw new NotFoundException("Organization not found.");
}
if (!organization.LicenseKey.Equals(model.LicenseKey))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
{
throw new BadRequestException("Invalid Billing Sync Key");
}
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
return license;
}
}

View File

@@ -2,6 +2,7 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Api.Billing.Models.Requests;
@@ -12,4 +13,11 @@ public class KeyPairRequestBody
public string PublicKey { get; set; }
[Required(ErrorMessage = "'encryptedPrivateKey' must be provided")]
public string EncryptedPrivateKey { get; set; }
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
{
return new PublicKeyEncryptionKeyPairData(
wrappedPrivateKey: EncryptedPrivateKey,
publicKey: PublicKey);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
@@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
namespace Bit.Api.Dirt.Controllers;
[Route("events")]
[Authorize("Application")]

View File

@@ -2,7 +2,7 @@
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Response;
namespace Bit.Api.Dirt.Models.Response;
public class EventResponseModel : ResponseModel
{

View File

@@ -1,6 +1,5 @@

using System.Net;
using Bit.Api.Models.Public.Request;
using System.Net;
using Bit.Api.Dirt.Public.Models;
using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
@@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Public.Controllers;
namespace Bit.Api.Dirt.Public.Controllers;
[Route("public/events")]
[Authorize("Organization")]

View File

@@ -3,7 +3,7 @@
using Bit.Core.Exceptions;
namespace Bit.Api.Models.Public.Request;
namespace Bit.Api.Dirt.Public.Models;
public class EventFilterRequestModel
{

View File

@@ -1,8 +1,9 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Models.Public.Response;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Public.Response;
namespace Bit.Api.Dirt.Public.Models;
/// <summary>
/// An event log.

View File

@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
{
_userService = userService;
_featureService = featureService;
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
}
[HttpPost("key-management/regenerate-keys")]
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
if (model.IsV2Request())
{
return;
// V2 account registration
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
}
foreach (var error in result.Errors)
else
{
ModelState.AddModelError(string.Empty, error.Description);
}
// V1 account registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
throw new BadRequestException(ModelState);
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
}
[HttpPost("convert-to-key-connector")]

View File

@@ -1,36 +1,112 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class SetKeyConnectorKeyRequestModel
public class SetKeyConnectorKeyRequestModel : IValidatableObject
{
[Required]
public string Key { get; set; }
[Required]
public KeysRequestModel Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
[Required]
public string OrgIdentifier { get; set; }
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
public string? Key { get; set; }
[Obsolete("Use AccountKeys instead")]
public KeysRequestModel? Keys { get; set; }
[Obsolete("Not used anymore")]
public KdfType? Kdf { get; set; }
[Obsolete("Not used anymore")]
public int? KdfIterations { get; set; }
[Obsolete("Not used anymore")]
public int? KdfMemory { get; set; }
[Obsolete("Not used anymore")]
public int? KdfParallelism { get; set; }
[EncryptedString]
public string? KeyConnectorKeyWrappedUserKey { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
[Required]
public required string OrgIdentifier { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
{
// V2 registration
yield break;
}
// V1 registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(Key))
{
yield return new ValidationResult("Key must be supplied.");
}
if (Keys == null)
{
yield return new ValidationResult("Keys must be supplied.");
}
if (Kdf == null)
{
yield return new ValidationResult("Kdf must be supplied.");
}
if (KdfIterations == null)
{
yield return new ValidationResult("KdfIterations must be supplied.");
}
if (Kdf == KdfType.Argon2id)
{
if (KdfMemory == null)
{
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
}
if (KdfParallelism == null)
{
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
}
}
}
public bool IsV2Request()
{
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
}
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
public User ToUser(User existingUser)
{
existingUser.Kdf = Kdf;
existingUser.KdfIterations = KdfIterations;
existingUser.Kdf = Kdf!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys.ToUser(existingUser);
Keys!.ToUser(existingUser);
return existingUser;
}
public KeyConnectorKeysData ToKeyConnectorKeysData()
{
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
{
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
}
return new KeyConnectorKeysData
{
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
AccountKeys = AccountKeys,
OrgIdentifier = OrgIdentifier
};
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core.Exceptions;

View File

@@ -1,4 +1,4 @@
using Bit.Api.Models.Public.Request;
using Bit.Api.Dirt.Public.Models;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.Services;
@@ -49,7 +49,7 @@ public static class EventDiagnosticLogger
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
IEnumerable<Dirt.Models.Response.EventResponseModel> data,
string? continuationToken,
DateTime? queryStart = null,
DateTime? queryEnd = null)

View File

@@ -9,10 +9,7 @@ public class BillingSettings
public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; }
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
public class PayPalSettings
{
@@ -21,35 +18,4 @@ public class BillingSettings
public virtual string WebhookKey { get; set; }
}
public class FreshDeskSettings
{
public virtual string ApiKey { get; set; }
public virtual string WebhookKey { get; set; }
/// <summary>
/// Indicates the data center region. Valid values are "US" and "EU"
/// </summary>
public virtual string Region { get; set; }
public virtual string UserFieldName { get; set; }
public virtual string OrgFieldName { get; set; }
public virtual bool RemoveNewlinesInReplies { get; set; } = false;
public virtual string AutoReplyGreeting { get; set; } = string.Empty;
public virtual string AutoReplySalutation { get; set; } = string.Empty;
}
public class OnyxSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
public virtual string Path { get; set; }
public virtual int PersonaId { get; set; }
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
}
public class SearchSettings
{
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
public virtual bool RealTime { get; set; } = true;
}
}

View File

@@ -1,395 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Web;
using Bit.Billing.Models;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Markdig;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
[Route("freshdesk")]
public class FreshdeskController : Controller
{
private readonly BillingSettings _billingSettings;
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly ILogger<FreshdeskController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IHttpClientFactory _httpClientFactory;
public FreshdeskController(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOptions<BillingSettings> billingSettings,
ILogger<FreshdeskController> logger,
GlobalSettings globalSettings,
IHttpClientFactory httpClientFactory)
{
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_logger = logger;
_globalSettings = globalSettings;
_httpClientFactory = httpClientFactory;
}
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,
[FromBody, Required] FreshdeskWebhookModel model)
{
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return new BadRequestResult();
}
try
{
var ticketId = model.TicketId;
var ticketContactEmail = model.TicketContactEmail;
var ticketTags = model.TicketTags;
if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail))
{
return new BadRequestResult();
}
var updateBody = new Dictionary<string, object>();
var note = string.Empty;
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
var customFields = new Dictionary<string, object>();
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
if (user == null)
{
note += $"<li>No user found: {ticketContactEmail}</li>";
await CreateNote(ticketId, note);
}
if (user != null)
{
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
note += $"<li>User, {user.Email}: {userLink}</li>";
customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink);
var tags = new HashSet<string>();
if (user.Premium)
{
tags.Add("Premium");
}
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
foreach (var org in orgs)
{
// Prevent org names from injecting any additional HTML
var orgName = HttpUtility.HtmlEncode(org.Name);
var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " +
$"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}";
note += $"<li>Org, {orgNote}</li>";
if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName))
{
customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote);
}
else
{
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
}
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
if (!string.IsNullOrWhiteSpace(planName))
{
tags.Add(string.Format("Org: {0}", planName));
}
}
if (tags.Any())
{
var tagsToUpdate = tags.ToList();
if (!string.IsNullOrWhiteSpace(ticketTags))
{
var splitTicketTags = ticketTags.Split(',');
for (var i = 0; i < splitTicketTags.Length; i++)
{
tagsToUpdate.Insert(i, splitTicketTags[i]);
}
}
updateBody.Add("tags", tagsToUpdate);
}
if (customFields.Any())
{
updateBody.Add("custom_fields", customFields);
}
var updateRequest = new HttpRequestMessage(HttpMethod.Put,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId))
{
Content = JsonContent.Create(updateBody),
};
await CallFreshdeskApiAsync(updateRequest);
await CreateNote(ticketId, note);
}
return new OkResult();
}
catch (Exception e)
{
_logger.LogError(e, "Error processing freshdesk webhook.");
return new BadRequestResult();
}
}
[HttpPost("webhook-onyx-ai")]
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
{
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key))
{
return new BadRequestResult();
}
// if there is no description, then we don't send anything to onyx
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
{
return Ok();
}
// Get response from Onyx AI
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the answer as a note to the ticket
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
[HttpPost("webhook-onyx-ai-reply")]
public async Task<IActionResult> PostWebhookOnyxAiReply([FromQuery, Required] string key,
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
{
// NOTE:
// at this time, this endpoint is a duplicate of `webhook-onyx-ai`
// eventually, we will merge both endpoints into one webhook for Freshdesk
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid)
{
return new BadRequestResult();
}
// if there is no description, then we don't send anything to onyx
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
{
return Ok();
}
// create the onyx `answer-with-citation` request
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the reply to the ticket
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
private bool IsValidRequestFromFreshdesk(string key)
{
if (string.IsNullOrWhiteSpace(key)
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return false;
}
return true;
}
private async Task CreateNote(string ticketId, string note)
{
var noteBody = new Dictionary<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
await CallFreshdeskApiAsync(noteRequest);
}
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
var noteBody = new Dictionary<string, object>
{
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addNoteResponse.ToString());
}
}
private async Task AddReplyToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
// convert note from markdown to html
var htmlNote = note;
try
{
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
htmlNote = Markdig.Markdown.ToHtml(note, pipeline);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}",
ticketId, note);
htmlNote = note; // fallback to the original note
}
// clear out any new lines that Freshdesk doesn't like
if (_billingSettings.FreshDesk.RemoveNewlinesInReplies)
{
htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty);
}
var replyBody = new FreshdeskReplyRequestModel
{
Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}",
};
var replyRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId))
{
Content = JsonContent.Create(replyBody),
};
var addReplyResponse = await CallFreshdeskApiAsync(replyRequest);
if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addReplyResponse.ToString());
}
}
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
{
try
{
var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X"));
var httpClient = _httpClientFactory.CreateClient("FreshdeskApi");
request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3)
{
return response;
}
}
catch
{
if (retriedCount > 3)
{
throw;
}
}
await Task.Delay(30000 * (retriedCount + 1));
return await CallFreshdeskApiAsync(request, retriedCount++);
}
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
{
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
{
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
return (onyxRequest, onyxResponse);
}
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
{
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
return (request, onyxSimpleResponse);
}
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
{
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
response.StatusCode, JsonSerializer.Serialize(response));
return new T();
}
var responseStr = await response.Content.ReadAsStringAsync();
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return responseJson ?? new T();
}
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
}
}

View File

@@ -1,248 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
[Route("freshsales")]
public class FreshsalesController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings;
private readonly string _freshsalesApiKey;
private readonly HttpClient _httpClient;
public FreshsalesController(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOptions<BillingSettings> billingSettings,
ILogger<FreshsalesController> logger,
GlobalSettings globalSettings)
{
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_logger = logger;
_globalSettings = globalSettings;
_httpClient = new HttpClient
{
BaseAddress = new Uri("https://bitwarden.freshsales.io/api/")
};
_freshsalesApiKey = billingSettings.Value.FreshsalesApiKey;
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Token",
$"token={_freshsalesApiKey}");
}
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromHeader(Name = "Authorization")] string key,
[FromBody] CustomWebhookRequestModel request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key))
{
return Unauthorized();
}
try
{
var leadResponse = await _httpClient.GetFromJsonAsync<LeadWrapper<FreshsalesLeadModel>>(
$"leads/{request.LeadId}",
cancellationToken);
var lead = leadResponse.Lead;
var primaryEmail = lead.Emails
.Where(e => e.IsPrimary)
.FirstOrDefault();
if (primaryEmail == null)
{
return BadRequest(new { Message = "Lead has not primary email." });
}
var user = await _userRepository.GetByEmailAsync(primaryEmail.Value);
if (user == null)
{
return NoContent();
}
var newTags = new HashSet<string>();
if (user.Premium)
{
newTags.Add("Premium");
}
var noteItems = new List<string>
{
$"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"
};
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
foreach (var org in orgs)
{
noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
if (TryGetPlanName(org.PlanType, out var planName))
{
newTags.Add($"Org: {planName}");
}
}
if (newTags.Any())
{
var allTags = newTags.Concat(lead.Tags);
var updateLeadResponse = await _httpClient.PutAsJsonAsync(
$"leads/{request.LeadId}",
CreateWrapper(new { tags = allTags }),
cancellationToken);
updateLeadResponse.EnsureSuccessStatusCode();
}
var createNoteResponse = await _httpClient.PostAsJsonAsync(
"notes",
CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken);
createNoteResponse.EnsureSuccessStatusCode();
return NoContent();
}
catch (Exception ex)
{
Console.WriteLine(ex);
_logger.LogError(ex, "Error processing freshsales webhook");
return BadRequest(new { ex.Message });
}
}
private static LeadWrapper<T> CreateWrapper<T>(T lead)
{
return new LeadWrapper<T>
{
Lead = lead,
};
}
private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content)
{
return new CreateNoteRequestModel
{
Note = new EditNoteModel
{
Description = content,
TargetableType = "Lead",
TargetableId = leadId,
},
};
}
private static bool TryGetPlanName(PlanType planType, out string planName)
{
switch (planType)
{
case PlanType.Free:
planName = "Free";
return true;
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
case PlanType.FamiliesAnnually2019:
planName = "Families";
return true;
case PlanType.TeamsAnnually:
case PlanType.TeamsAnnually2023:
case PlanType.TeamsAnnually2020:
case PlanType.TeamsAnnually2019:
case PlanType.TeamsMonthly:
case PlanType.TeamsMonthly2023:
case PlanType.TeamsMonthly2020:
case PlanType.TeamsMonthly2019:
case PlanType.TeamsStarter:
case PlanType.TeamsStarter2023:
planName = "Teams";
return true;
case PlanType.EnterpriseAnnually:
case PlanType.EnterpriseAnnually2023:
case PlanType.EnterpriseAnnually2020:
case PlanType.EnterpriseAnnually2019:
case PlanType.EnterpriseMonthly:
case PlanType.EnterpriseMonthly2023:
case PlanType.EnterpriseMonthly2020:
case PlanType.EnterpriseMonthly2019:
planName = "Enterprise";
return true;
case PlanType.Custom:
planName = "Custom";
return true;
default:
planName = null;
return false;
}
}
}
public class CustomWebhookRequestModel
{
[JsonPropertyName("leadId")]
public long LeadId { get; set; }
}
public class LeadWrapper<T>
{
[JsonPropertyName("lead")]
public T Lead { get; set; }
public static LeadWrapper<TItem> Create<TItem>(TItem lead)
{
return new LeadWrapper<TItem>
{
Lead = lead,
};
}
}
public class FreshsalesLeadModel
{
public string[] Tags { get; set; }
public FreshsalesEmailModel[] Emails { get; set; }
}
public class FreshsalesEmailModel
{
[JsonPropertyName("value")]
public string Value { get; set; }
[JsonPropertyName("is_primary")]
public bool IsPrimary { get; set; }
}
public class CreateNoteRequestModel
{
[JsonPropertyName("note")]
public EditNoteModel Note { get; set; }
}
public class EditNoteModel
{
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("targetable_type")]
public string TargetableType { get; set; }
[JsonPropertyName("targetable_id")]
public long TargetableId { get; set; }
}

View File

@@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class FreshdeskReplyRequestModel
{
[JsonPropertyName("body")]
public required string Body { get; set; }
}

View File

@@ -1,24 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class FreshdeskWebhookModel
{
[JsonPropertyName("ticket_id")]
public string TicketId { get; set; }
[JsonPropertyName("ticket_contact_email")]
public string TicketContactEmail { get; set; }
[JsonPropertyName("ticket_tags")]
public string TicketTags { get; set; }
}
public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel
{
[JsonPropertyName("ticket_description_text")]
public string TicketDescriptionText { get; set; }
}

View File

@@ -1,75 +0,0 @@
using System.Text.Json.Serialization;
using static Bit.Billing.BillingSettings;
namespace Bit.Billing.Models;
public class OnyxRequestModel
{
[JsonPropertyName("persona_id")]
public int PersonaId { get; set; } = 1;
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
public OnyxRequestModel(OnyxSettings onyxSettings)
{
PersonaId = onyxSettings.PersonaId;
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
}
}
/// <summary>
/// This is used with the onyx endpoint /query/answer-with-citation
/// which has been deprecated. This can be removed once later
/// </summary>
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; } = new List<Message>();
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
}
}
/// <summary>
/// This is used with the onyx endpoint /chat/send-message-simple-api
/// </summary>
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
}
}
public class Message
{
[JsonPropertyName("message")]
public string MessageText { get; set; } = string.Empty;
[JsonPropertyName("sender")]
public string Sender { get; set; } = "user";
}
public class RetrievalOptions
{
[JsonPropertyName("run_search")]
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
[JsonPropertyName("real_time")]
public bool RealTime { get; set; } = true;
}
public class RetrievalOptionsRunSearch
{
public const string Always = "always";
public const string Never = "never";
public const string Auto = "auto";
}

View File

@@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; } = string.Empty;
[JsonPropertyName("answer_citationless")]
public string AnswerCitationless { get; set; } = string.Empty;
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; } = string.Empty;
}

View File

@@ -2,7 +2,6 @@
#nullable disable
using System.Globalization;
using System.Net.Http.Headers;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Commercial.Core.Utilities;
@@ -98,13 +97,6 @@ public class Startup
// Authentication
services.AddAuthentication();
// Set up HttpClients
services.AddHttpClient("FreshdeskApi");
services.AddHttpClient("OnyxApi", client =>
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
});
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();

View File

@@ -32,10 +32,5 @@
"connectionString": "UseDevelopmentStorage=true"
}
},
"billingSettings": {
"onyx": {
"personaId": 68
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}

View File

@@ -26,10 +26,7 @@
"payPal": {
"production": true,
"businessId": "4ZDA7DLUUJGMN"
},
"onyx": {
"personaId": 7
}
}
},
"Logging": {
"IncludeScopes": false,

View File

@@ -61,27 +61,6 @@
"production": false,
"businessId": "AD3LAUZSNVPJY",
"webhookKey": "SECRET"
},
"freshdesk": {
"apiKey": "SECRET",
"webhookKey": "SECRET",
"region": "US",
"userFieldName": "cf_user",
"orgFieldName": "cf_org",
"removeNewlinesInReplies": true,
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
"autoReplySalutation": "<br /><br />If this response doesnt fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
},
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api",
"path": "/chat/send-message-simple-api",
"useAnswerWithCitationModels": true,
"personaId": 7,
"searchSettings": {
"runSearch": "always",
"realTime": true
}
}
}
}

View File

@@ -99,8 +99,8 @@ public class CloudOrganizationSignUpCommand(
ReferenceData = signup.Owner.ReferenceData,
Enabled = true,
LicenseKey = CoreHelpers.SecureRandomString(20),
PublicKey = signup.PublicKey,
PrivateKey = signup.PrivateKey,
PublicKey = signup.Keys?.PublicKey,
PrivateKey = signup.Keys?.WrappedPrivateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,

View File

@@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
public static class OrganizationExtensions
{
/// <summary>
/// Updates the organization public and private keys if provided and not already set.
/// This is legacy code for old organizations that were not created with a public/private keypair.
/// It is a soft migration that will silently migrate organizations when they perform certain actions,
/// e.g. change their details or upgrade their plan.
/// </summary>
public static void BackfillPublicPrivateKeys(this Organization organization, PublicKeyEncryptionKeyPairData? keyPair)
{
// Only backfill if both new keys are provided and both old keys are missing.
if (string.IsNullOrWhiteSpace(keyPair?.PublicKey) ||
string.IsNullOrWhiteSpace(keyPair.WrappedPrivateKey) ||
!string.IsNullOrWhiteSpace(organization.PublicKey) ||
!string.IsNullOrWhiteSpace(organization.PrivateKey))
{
return;
}
organization.PublicKey = keyPair.PublicKey;
organization.PrivateKey = keyPair.WrappedPrivateKey;
}
}

View File

@@ -93,8 +93,8 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati
ReferenceData = signup.Owner.ReferenceData,
Enabled = true,
LicenseKey = CoreHelpers.SecureRandomString(20),
PublicKey = signup.PublicKey,
PrivateKey = signup.PrivateKey,
PublicKey = signup.Keys?.PublicKey,
PrivateKey = signup.Keys?.WrappedPrivateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created,

View File

@@ -39,8 +39,20 @@ public class OrganizationUpdateCommand(
var originalBillingEmail = organization.BillingEmail;
// Apply updates to organization
organization.UpdateDetails(request);
organization.BackfillPublicPrivateKeys(request);
// These values may or may not be sent by the client depending on the operation being performed.
// Skip any values not provided.
if (request.Name is not null)
{
organization.Name = request.Name;
}
if (request.BillingEmail is not null)
{
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
}
organization.BackfillPublicPrivateKeys(request.Keys);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
// Update billing information in Stripe if required
@@ -56,7 +68,7 @@ public class OrganizationUpdateCommand(
/// </summary>
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
{
organization.BackfillPublicPrivateKeys(request);
organization.BackfillPublicPrivateKeys(request.Keys);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
return organization;
}

View File

@@ -1,43 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
public static class OrganizationUpdateExtensions
{
/// <summary>
/// Updates the organization name and/or billing email.
/// Any null property on the request object will be skipped.
/// </summary>
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
{
// These values may or may not be sent by the client depending on the operation being performed.
// Skip any values not provided.
if (request.Name is not null)
{
organization.Name = request.Name;
}
if (request.BillingEmail is not null)
{
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
}
}
/// <summary>
/// Updates the organization public and private keys if provided and not already set.
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
/// migration that will silently migrate organizations when they change their details.
/// </summary>
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
{
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
{
organization.PublicKey = request.PublicKey;
}
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
{
organization.PrivateKey = request.EncryptedPrivateKey;
}
}
}

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
/// <summary>
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
@@ -22,12 +24,7 @@ public record OrganizationUpdateRequest
public string? BillingEmail { get; init; }
/// <summary>
/// The organization's public key to set (optional, only set if not already present on the organization).
/// The organization's public/private key pair to set (optional, only set if not already present on the organization).
/// </summary>
public string? PublicKey { get; init; }
/// <summary>
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
/// </summary>
public string? EncryptedPrivateKey { get; init; }
public PublicKeyEncryptionKeyPairData? Keys { get; init; }
}

View File

@@ -187,7 +187,6 @@ public static class FeatureFlagKeys
/* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure";
public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog";
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
@@ -212,6 +211,7 @@ public static class FeatureFlagKeys
public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component";
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
/* Mobile Team */
public const string AndroidImportLoginsFlow = "import-logins-flow";
@@ -238,12 +238,12 @@ public static class FeatureFlagKeys
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";

View File

@@ -0,0 +1,52 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.KeyManagement.Authorization;
public class KeyConnectorAuthorizationHandler : AuthorizationHandler<KeyConnectorOperationsRequirement, User>
{
private readonly ICurrentContext _currentContext;
public KeyConnectorAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
KeyConnectorOperationsRequirement requirement,
User user)
{
var authorized = requirement switch
{
not null when requirement == KeyConnectorOperations.Use => CanUse(user),
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
};
if (authorized)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool CanUse(User user)
{
// User cannot use Key Connector if they already use it
if (user.UsesKeyConnector)
{
return false;
}
// User cannot use Key Connector if they are an owner or admin of any organization
if (_currentContext.Organizations.Any(u =>
u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin))
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.KeyManagement.Authorization;
public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement
{
public KeyConnectorOperationsRequirement(string name)
{
Name = name;
}
}
public static class KeyConnectorOperations
{
public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use));
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.KeyManagement.Commands.Interfaces;
/// <summary>
/// Creates the user key and account cryptographic state for a new user registering
/// with Key Connector SSO configuration.
/// </summary>
public interface ISetKeyConnectorKeyCommand
{
Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData);
}

View File

@@ -0,0 +1,60 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Authorization;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.KeyManagement.Commands;
public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand
{
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
public SetKeyConnectorKeyCommand(
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IUserService userService,
IUserRepository userRepository)
{
_authorizationService = authorizationService;
_currentContext = currentContext;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_userService = userService;
_userRepository = userRepository;
}
public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData)
{
var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user,
KeyConnectorOperations.Use);
if (!authorizationResult.Succeeded)
{
throw new BadRequestException("Cannot use Key Connector");
}
var setKeyConnectorUserKeyTask =
_userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey);
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id,
keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]);
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user,
_userService);
}
}

View File

@@ -1,9 +1,11 @@
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Authorization;
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Kdf.Implementations;
using Bit.Core.KeyManagement.Queries;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.KeyManagement;
@@ -12,15 +14,22 @@ public static class KeyManagementServiceCollectionExtensions
{
public static void AddKeyManagementServices(this IServiceCollection services)
{
services.AddKeyManagementAuthorizationHandlers();
services.AddKeyManagementCommands();
services.AddKeyManagementQueries();
services.AddSendPasswordServices();
}
private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, KeyConnectorAuthorizationHandler>();
}
private static void AddKeyManagementCommands(this IServiceCollection services)
{
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
services.AddScoped<IChangeKdfCommand, ChangeKdfCommand>();
services.AddScoped<ISetKeyConnectorKeyCommand, SetKeyConnectorKeyCommand>();
}
private static void AddKeyManagementQueries(this IServiceCollection services)

View File

@@ -0,0 +1,12 @@
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Core.KeyManagement.Models.Data;
public class KeyConnectorKeysData
{
public required string KeyConnectorKeyWrappedUserKey { get; set; }
public required AccountKeysRequestModel AccountKeys { get; set; }
public required string OrgIdentifier { get; init; }
}

View File

@@ -2,6 +2,7 @@
#nullable disable
using Bit.Core.Billing.Enums;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.Models.Business;
@@ -13,8 +14,7 @@ public class OrganizationUpgrade
public short AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
public TaxInfo TaxInfo { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public PublicKeyEncryptionKeyPairData Keys { get; set; }
public int? AdditionalSmSeats { get; set; }
public int? AdditionalServiceAccounts { get; set; }
public bool UseSecretsManager { get; set; }

View File

@@ -4,6 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@@ -256,27 +257,20 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
organization.SelfHost = newPlan.HasSelfHost;
organization.UsePolicies = newPlan.HasPolicies;
organization.MaxStorageGb = (short)(newPlan.PasswordManager.BaseStorageGb + upgrade.AdditionalStorageGb);
organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents;
organization.UseTotp = newPlan.HasTotp;
organization.Use2fa = newPlan.Has2fa;
organization.UseApi = newPlan.HasApi;
organization.UseSso = newPlan.HasSso;
organization.UseOrganizationDomains = newPlan.HasOrganizationDomains;
organization.UseKeyConnector = newPlan.HasKeyConnector ? organization.UseKeyConnector : false;
organization.UseScim = newPlan.HasScim;
organization.UseResetPassword = newPlan.HasResetPassword;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon;
organization.UseCustomPermissions = newPlan.HasCustomPermissions;
organization.Plan = newPlan.Name;
organization.Enabled = success;
organization.PublicKey = upgrade.PublicKey;
organization.PrivateKey = upgrade.PrivateKey;
organization.UsePasswordManager = true;
organization.UseSecretsManager = upgrade.UseSecretsManager;
organization.BackfillPublicPrivateKeys(upgrade.Keys);
if (upgrade.UseSecretsManager)
{
organization.SmSeats = newPlan.SecretsManager.BaseSeats + upgrade.AdditionalSmSeats.GetValueOrDefault();

View File

@@ -72,6 +72,8 @@ public interface IUserRepository : IRepository<User, Guid>
UserAccountKeysData accountKeysData,
IEnumerable<UpdateUserData>? updateUserDataActions = null);
Task DeleteManyAsync(IEnumerable<User> users);
UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey);
}
public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,

View File

@@ -33,6 +33,8 @@ public interface IUserService
Task<IdentityResult> ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword,
string token, string key);
Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key);
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
[Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")]
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);

View File

@@ -621,6 +621,7 @@ public class UserService : UserManager<User>, IUserService
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
public async Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier)
{
var identityResult = CheckCanUseKeyConnector(user);

View File

@@ -1029,11 +1029,8 @@ public class CipherService : ICipherService
var existingCipherData = DeserializeCipherData(existingCipher);
var newCipherData = DeserializeCipherData(cipher);
// "hidden password" users may not add cipher key encryption
if (existingCipher.Key == null && cipher.Key != null)
{
throw new BadRequestException("You do not have permission to add cipher key encryption.");
}
// For hidden-password users, never allow Key to change at all.
cipher.Key = existingCipher.Key;
// Keep only non-hidden fileds from the new cipher
var nonHiddenFields = newCipherData.Fields?.Where(f => f.Type != FieldType.Hidden) ?? [];
// Get hidden fields from the existing cipher

View File

@@ -21,17 +21,21 @@ public class CollectController : Controller
private readonly IEventService _eventService;
private readonly ICipherRepository _cipherRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
public CollectController(
ICurrentContext currentContext,
IEventService eventService,
ICipherRepository cipherRepository,
IOrganizationRepository organizationRepository)
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository
)
{
_currentContext = currentContext;
_eventService = eventService;
_cipherRepository = cipherRepository;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
}
[HttpPost]
@@ -54,6 +58,24 @@ public class CollectController : Controller
await _eventService.LogUserEventAsync(_currentContext.UserId.Value, eventModel.Type, eventModel.Date);
break;
case EventType.Organization_ItemOrganization_Accepted:
case EventType.Organization_ItemOrganization_Declined:
if (!eventModel.OrganizationId.HasValue || !_currentContext.UserId.HasValue)
{
continue;
}
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(eventModel.OrganizationId.Value, _currentContext.UserId.Value);
if (orgUser == null)
{
continue;
}
await _eventService.LogOrganizationUserEventAsync(orgUser, eventModel.Type, eventModel.Date);
continue;
// Cipher events
case EventType.Cipher_ClientAutofilled:
case EventType.Cipher_ClientCopiedHiddenField:

View File

@@ -659,6 +659,7 @@ public abstract class BaseRequestValidator<T> where T : class
var customResponse = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
{
// PrivateKey usage is now deprecated in favor of AccountKeys
customResponse.Add("PrivateKey", user.PrivateKey);
var accountKeys = await _accountKeysQuery.Run(user);
customResponse.Add("AccountKeys", new PrivateKeysResponseModel(accountKeys));
@@ -666,11 +667,13 @@ public abstract class BaseRequestValidator<T> where T : class
if (!string.IsNullOrWhiteSpace(user.Key))
{
// Key is deprecated in favor of UserDecryptionOptions.MasterPasswordUnlock.MasterKeyEncryptedUserKey
customResponse.Add("Key", user.Key);
}
customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
customResponse.Add("Kdf", (byte)user.Kdf);
customResponse.Add("KdfIterations", user.KdfIterations);
customResponse.Add("KdfMemory", user.KdfMemory);

View File

@@ -4,6 +4,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
@@ -154,7 +155,23 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{
// KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
context.Result.CustomResponse["ResetMasterPassword"] = false;
}
return Task.CompletedTask;
}
// Key connector data should have already been set in the decryption options
// for backwards compatibility we set them this way too. We can eventually get rid of this once we clean up
// ResetMasterPassword
if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) ||
userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions)
{
return Task.CompletedTask;
}
if (userDecryptionOptions is { KeyConnectorOption: { } })
{
context.Result.CustomResponse["ResetMasterPassword"] = false;
}
return Task.CompletedTask;

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Bit.Core;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
@@ -401,6 +402,32 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
return result.SingleOrDefault();
}
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
{
return async (connection, transaction) =>
{
var timestamp = DateTime.UtcNow;
await connection!.ExecuteAsync(
"[dbo].[User_UpdateKeyConnectorUserKey]",
new
{
Id = userId,
Key = keyConnectorWrappedUserKey,
// Key Connector does not use KDF, so we set some defaults
Kdf = KdfType.Argon2id,
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
KdfMemory = AuthConstants.ARGON2_MEMORY.Default,
KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default,
UsesKeyConnector = true,
RevisionDate = timestamp,
AccountRevisionDate = timestamp
},
transaction: transaction,
commandType: CommandType.StoredProcedure);
};
}
private async Task ProtectDataAndSaveAsync(User user, Func<Task> saveTask)
{
if (user == null)

View File

@@ -1,5 +1,7 @@
using AutoMapper;
using Bit.Core;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
@@ -479,6 +481,35 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
}
}
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
{
return async (_, _) =>
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var userEntity = await dbContext.Users.FindAsync(userId);
if (userEntity == null)
{
throw new ArgumentException("User not found", nameof(userId));
}
var timestamp = DateTime.UtcNow;
userEntity.Key = keyConnectorWrappedUserKey;
// Key Connector does not use KDF, so we set some defaults
userEntity.Kdf = KdfType.Argon2id;
userEntity.KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default;
userEntity.KdfMemory = AuthConstants.ARGON2_MEMORY.Default;
userEntity.KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default;
userEntity.UsesKeyConnector = true;
userEntity.RevisionDate = timestamp;
userEntity.AccountRevisionDate = timestamp;
await dbContext.SaveChangesAsync();
};
}
private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable<Guid> userIds)
{
var defaultCollections = (from c in dbContext.Collections

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