1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-01 16:50:36 -05:00

Merge branch 'main' into feat/pm-14496-non-root-self-hosted-images

This commit is contained in:
tangowithfoxtrot 2025-05-28 07:53:31 -07:00 committed by GitHub
commit c3f4a96b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
106 changed files with 21645 additions and 781 deletions

View File

@ -15,8 +15,7 @@
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml",
".github/workflows/repository-management.yml"
".github/workflows/release.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
@ -134,8 +133,8 @@
reviewers: ["team:dept-dbops"],
},
{
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
description: "DevOps owned dependencies",
matchPackageNames: ["YamlDotNet"],
description: "BRE owned dependencies",
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
},

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "-",
"dependencies": {
"bootstrap": "5.3.3",
"bootstrap": "5.3.6",
"font-awesome": "4.7.0",
"jquery": "3.7.1"
},
@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"sass": "1.88.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack-cli": "5.1.4"
}
},
@ -455,13 +455,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@webassemblyjs/ast": {
@ -748,9 +748,9 @@
}
},
"node_modules/bootstrap": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
"funding": [
{
"type": "github",
@ -781,9 +781,9 @@
}
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
"dev": true,
"funding": [
{
@ -801,10 +801,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.1"
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
@ -821,9 +821,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
"dev": true,
"funding": [
{
@ -975,9 +975,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
"dev": true,
"license": "ISC"
},
@ -1009,9 +1009,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
@ -1106,13 +1106,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
@ -1248,9 +1241,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
"dev": true,
"license": "MIT"
},
@ -1754,16 +1747,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -1877,9 +1860,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1898,9 +1881,9 @@
}
},
"node_modules/sass-loader": {
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz",
"integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==",
"version": "16.0.5",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz",
"integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1939,9 +1922,9 @@
}
},
"node_modules/schema-utils": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
"integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1959,9 +1942,9 @@
}
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
@ -2078,9 +2061,9 @@
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2088,14 +2071,14 @@
}
},
"node_modules/terser": {
"version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"version": "5.39.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@ -2156,9 +2139,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@ -2193,16 +2176,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -2211,9 +2184,9 @@
"license": "MIT"
},
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2225,14 +2198,15 @@
}
},
"node_modules/webpack": {
"version": "5.97.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
"version": "5.99.8",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
@ -2249,9 +2223,9 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^3.2.0",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.10",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
"webpack-sources": "^3.2.3"
},
@ -2352,59 +2326,6 @@
"node": ">=10.13.0"
}
},
"node_modules/webpack/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/webpack/node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^6.9.1"
}
},
"node_modules/webpack/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -8,7 +8,7 @@
"build": "webpack"
},
"dependencies": {
"bootstrap": "5.3.3",
"bootstrap": "5.3.6",
"font-awesome": "4.7.0",
"jquery": "3.7.1"
},
@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"sass": "1.88.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack-cli": "5.1.4"
}
}

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "GPL-3.0",
"dependencies": {
"bootstrap": "5.3.3",
"bootstrap": "5.3.6",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"toastr": "2.1.4"
@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"sass": "1.88.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack-cli": "5.1.4"
}
},
@ -456,13 +456,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@webassemblyjs/ast": {
@ -749,9 +749,9 @@
}
},
"node_modules/bootstrap": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
"funding": [
{
"type": "github",
@ -782,9 +782,9 @@
}
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
"dev": true,
"funding": [
{
@ -802,10 +802,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.1"
"update-browserslist-db": "^1.1.3"
},
"bin": {
"browserslist": "cli.js"
@ -822,9 +822,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
"dev": true,
"funding": [
{
@ -976,9 +976,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
"dev": true,
"license": "ISC"
},
@ -1010,9 +1010,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
@ -1107,13 +1107,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
@ -1249,9 +1242,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
"dev": true,
"license": "MIT"
},
@ -1755,16 +1748,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -1878,9 +1861,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1899,9 +1882,9 @@
}
},
"node_modules/sass-loader": {
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz",
"integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==",
"version": "16.0.5",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz",
"integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1940,9 +1923,9 @@
}
},
"node_modules/schema-utils": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
"integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1960,9 +1943,9 @@
}
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
@ -2079,9 +2062,9 @@
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2089,14 +2072,14 @@
}
},
"node_modules/terser": {
"version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"version": "5.39.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@ -2165,9 +2148,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@ -2202,16 +2185,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -2220,9 +2193,9 @@
"license": "MIT"
},
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2234,14 +2207,15 @@
}
},
"node_modules/webpack": {
"version": "5.97.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
"version": "5.99.8",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
@ -2258,9 +2232,9 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^3.2.0",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.10",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
"webpack-sources": "^3.2.3"
},
@ -2361,59 +2335,6 @@
"node": ">=10.13.0"
}
},
"node_modules/webpack/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/webpack/node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^6.9.1"
}
},
"node_modules/webpack/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.8",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -8,7 +8,7 @@
"build": "webpack"
},
"dependencies": {
"bootstrap": "5.3.3",
"bootstrap": "5.3.6",
"font-awesome": "4.7.0",
"jquery": "3.7.1",
"toastr": "2.1.4"
@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"sass": "1.88.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack-cli": "5.1.4"
}
}

View File

@ -2,10 +2,10 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;

View File

@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
#nullable enable

View File

@ -90,6 +90,13 @@ public class EmergencyAccessGrantorDetailsResponseModel : EmergencyAccessRespons
public class EmergencyAccessTakeoverResponseModel : ResponseModel
{
/// <summary>
/// Creates a new instance of the <see cref="EmergencyAccessTakeoverResponseModel"/> class.
/// </summary>
/// <param name="emergencyAccess">Consumed for the Encrypted Key value</param>
/// <param name="grantor">consumed for the KDF configuration</param>
/// <param name="obj">name of the object</param>
/// <exception cref="ArgumentNullException">emergencyAccess cannot be null</exception>
public EmergencyAccessTakeoverResponseModel(EmergencyAccess emergencyAccess, User grantor, string obj = "emergencyAccessTakeover") : base(obj)
{
if (emergencyAccess == null)

View File

@ -302,8 +302,12 @@ public class OrganizationBillingController(
Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine.");
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
await organizationBillingService.Finalize(sale);
var updatedOrg = await organizationRepository.GetByIdAsync(organizationId);
if (updatedOrg != null)
{
await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation);
}
return TypedResults.Ok();
}

View File

@ -28,10 +28,8 @@ using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.Billing;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
@ -224,18 +222,8 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Slack
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
// Add SlackService for OAuth API requests - if configured
services.AddSlackService(globalSettings);
}
public void Configure(

View File

@ -151,6 +151,16 @@ public class CiphersController : Controller
public async Task<CipherResponseModel> Post([FromBody] CipherRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
// Validate the model was encrypted for the posting user
if (model.EncryptedFor != null)
{
if (model.EncryptedFor != user.Id)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
var cipher = model.ToCipherDetails(user.Id);
if (cipher.OrganizationId.HasValue && !await _currentContext.OrganizationUser(cipher.OrganizationId.Value))
{
@ -170,6 +180,16 @@ public class CiphersController : Controller
public async Task<CipherResponseModel> PostCreate([FromBody] CipherCreateRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
// Validate the model was encrypted for the posting user
if (model.Cipher.EncryptedFor != null)
{
if (model.Cipher.EncryptedFor != user.Id)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
var cipher = model.Cipher.ToCipherDetails(user.Id);
if (cipher.OrganizationId.HasValue && !await _currentContext.OrganizationUser(cipher.OrganizationId.Value))
{
@ -192,6 +212,16 @@ public class CiphersController : Controller
}
var userId = _userService.GetProperUserId(User).Value;
// Validate the model was encrypted for the posting user
if (model.Cipher.EncryptedFor != null)
{
if (model.Cipher.EncryptedFor != userId)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
await _cipherService.SaveAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, true, false);
var response = new CipherMiniResponseModel(cipher, _globalSettings, false);
@ -209,6 +239,15 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Validate the model was encrypted for the posting user
if (model.EncryptedFor != null)
{
if (model.EncryptedFor != user.Id)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
ValidateClientVersionForFido2CredentialSupport(cipher);
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id)).Select(c => c.CollectionId).ToList();
@ -237,6 +276,15 @@ public class CiphersController : Controller
var userId = _userService.GetProperUserId(User).Value;
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(id);
// Validate the model was encrypted for the posting user
if (model.EncryptedFor != null)
{
if (model.EncryptedFor != userId)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
ValidateClientVersionForFido2CredentialSupport(cipher);
if (cipher == null || !cipher.OrganizationId.HasValue ||
@ -658,6 +706,15 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Validate the model was encrypted for the posting user
if (model.Cipher.EncryptedFor != null)
{
if (model.Cipher.EncryptedFor != user.Id)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone();
@ -1019,6 +1076,18 @@ public class CiphersController : Controller
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 != userId)
{
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
}
}
var shareCiphers = new List<(Cipher, DateTime?)>();
foreach (var cipher in model.Ciphers)
{

View File

@ -11,6 +11,10 @@ namespace Bit.Api.Vault.Models.Request;
public class CipherRequestModel
{
/// <summary>
/// The Id of the user that encrypted the cipher. It should always represent a UserId.
/// </summary>
public Guid? EncryptedFor { get; set; }
public CipherType Type { get; set; }
[StringLength(36)]

View File

@ -7,3 +7,19 @@ public enum IntegrationType : int
Slack = 3,
Webhook = 4,
}
public static class IntegrationTypeExtensions
{
public static string ToRoutingKey(this IntegrationType type)
{
switch (type)
{
case IntegrationType.Slack:
return "slack";
case IntegrationType.Webhook:
return "webhook";
default:
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
}
}
}

View File

@ -0,0 +1,12 @@
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; }
void ApplyRetry(DateTime? handlerDelayUntilDate);
string ToJson();
}

View File

@ -0,0 +1,16 @@
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public class IntegrationHandlerResult
{
public IntegrationHandlerResult(bool success, IIntegrationMessage message)
{
Success = success;
Message = message;
}
public bool Success { get; set; } = false;
public bool Retryable { get; set; } = false;
public IIntegrationMessage Message { get; set; }
public DateTime? DelayUntilDate { get; set; }
public string FailureReason { get; set; } = string.Empty;
}

View File

@ -0,0 +1,34 @@
using System.Text.Json;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public class IntegrationMessage<T> : IIntegrationMessage
{
public IntegrationType IntegrationType { get; set; }
public T Configuration { get; set; }
public string RenderedTemplate { get; set; }
public int RetryCount { get; set; } = 0;
public DateTime? DelayUntilDate { get; set; }
public void ApplyRetry(DateTime? handlerDelayUntilDate)
{
RetryCount++;
var baseTime = handlerDelayUntilDate ?? DateTime.UtcNow;
var backoffSeconds = Math.Pow(2, RetryCount);
var jitterSeconds = Random.Shared.Next(0, 3);
DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds);
}
public string ToJson()
{
return JsonSerializer.Serialize(this);
}
public static IntegrationMessage<T> FromJson(string json)
{
return JsonSerializer.Deserialize<IntegrationMessage<T>>(json);
}
}

View File

@ -1,10 +1,11 @@
using Bit.Core.AdminConsole.Entities;
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
#nullable enable
namespace Bit.Core.Models.Data.Integrations;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public class IntegrationTemplateContext(EventMessage eventMessage)
{

View File

@ -1,3 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -1,3 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegrationConfiguration(string channelId);

View File

@ -1,3 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -1,3 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
public record WebhookIntegrationConfiguration(string url);

View File

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

View File

@ -1,3 +0,0 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfigurationDetils(string url);

View File

@ -0,0 +1,24 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
namespace Bit.Core.Services;
public interface IIntegrationHandler
{
Task<IntegrationHandlerResult> HandleAsync(string json);
}
public interface IIntegrationHandler<T> : IIntegrationHandler
{
Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<T> message);
}
public abstract class IntegrationHandlerBase<T> : IIntegrationHandler<T>
{
public async Task<IntegrationHandlerResult> HandleAsync(string json)
{
var message = IntegrationMessage<T>.FromJson(json);
return await HandleAsync(message);
}
public abstract Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<T> message);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
namespace Bit.Core.Services;
public interface IIntegrationPublisher
{
Task PublishAsync(IIntegrationMessage message);
}

View File

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

View File

@ -1,8 +1,8 @@
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.Models.Data.Integrations;
using Bit.Core.Repositories;
namespace Bit.Core.Services;

View File

@ -29,7 +29,7 @@ public class RabbitMqEventListenerService : EventLoggingListenerService
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
_exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
_logger = logger;
_queueName = queueName;
}

View File

@ -18,7 +18,7 @@ public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable
UserName = globalSettings.EventLogging.RabbitMq.Username,
Password = globalSettings.EventLogging.RabbitMq.Password
};
_exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
}

View File

@ -0,0 +1,191 @@
using System.Text;
using Bit.Core.Settings;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
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 IIntegrationHandler _handler;
private readonly ConnectionFactory _factory;
private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
private readonly int _retryTiming;
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
string routingKey,
string queueName,
string retryQueueName,
string deadLetterQueueName,
GlobalSettings globalSettings,
ILogger<RabbitMqIntegrationListenerService> logger)
{
_handler = handler;
_routingKey = routingKey;
_retryRoutingKey = $"{_routingKey}-retry";
_queueName = queueName;
_retryQueueName = retryQueueName;
_deadLetterQueueName = deadLetterQueueName;
_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
};
}
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<string, object>
{
{ "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 base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
var consumer = new AsyncEventingBasicConsumer(_channel);
consumer.ReceivedAsync += async (_, ea) =>
{
var json = Encoding.UTF8.GetString(ea.Body.Span);
try
{
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)
{
// 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.");
}
}
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.");
}
// 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);
}
};
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));
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await _channel.CloseAsync(cancellationToken);
await _connection.CloseAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
public override void Dispose()
{
_channel.Dispose();
_connection.Dispose();
base.Dispose();
}
}

View File

@ -0,0 +1,54 @@
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<Task<IConnection>> _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<Task<IConnection>>(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<IConnection> CreateConnectionAsync()
{
return await _factory.CreateConnectionAsync();
}
}

View File

@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable

View File

@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
namespace Bit.Core.Services;
public class SlackIntegrationHandler(
ISlackService slackService)
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
{
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{
await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.token,
message.RenderedTemplate,
message.Configuration.channelId
);
return new IntegrationHandlerResult(success: true, message: message);
}
}

View File

@ -1,8 +1,8 @@
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.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
@ -25,7 +25,7 @@ public class WebhookEventHandler(
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration,
string renderedTemplate)
{
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetails>();
if (config is null || string.IsNullOrEmpty(config.url))
{
return;

View File

@ -0,0 +1,61 @@
using System.Globalization;
using System.Net;
using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations;
#nullable enable
namespace Bit.Core.Services;
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
public const string HttpClientName = "WebhookIntegrationHandlerHttpClient";
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(message.Configuration.url, content);
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
switch (response.StatusCode)
{
case HttpStatusCode.TooManyRequests:
case HttpStatusCode.RequestTimeout:
case HttpStatusCode.InternalServerError:
case HttpStatusCode.BadGateway:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
result.Retryable = true;
result.FailureReason = response.ReasonPhrase;
if (response.Headers.TryGetValues("Retry-After", out var values))
{
var value = values.FirstOrDefault();
if (int.TryParse(value, out var seconds))
{
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds);
}
else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var retryDate))
{
// Retry-after was specified as a date. Adjust DelayUntilDate to the specified date.
result.DelayUntilDate = retryDate.UtcDateTime;
}
}
break;
default:
result.Retryable = false;
result.FailureReason = response.ReasonPhrase;
break;
}
return result;
}
}

View File

@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
#nullable enable
using System.Text.RegularExpressions;
namespace Bit.Core.AdminConsole.Utilities;
@ -9,7 +11,7 @@ public static partial class IntegrationTemplateProcessor
public static string ReplaceTokens(string template, object values)
{
if (string.IsNullOrEmpty(template) || values == null)
if (string.IsNullOrEmpty(template))
{
return template;
}

View File

@ -8,7 +8,7 @@ public class SsoUser : ITableObject<long>
public long Id { get; set; }
public Guid UserId { get; set; }
public Guid? OrganizationId { get; set; }
[MaxLength(50)]
[MaxLength(300)]
public string ExternalId { get; set; }
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;

View File

@ -2,6 +2,12 @@
public enum EmergencyAccessType : byte
{
/// <summary>
/// Allows emergency contact to view the Grantor's data.
/// </summary>
View = 0,
/// <summary>
/// Allows emergency contact to take over the Grantor's account by overwriting the Grantor's password.
/// </summary>
Takeover = 1,
}

View File

@ -58,38 +58,38 @@ public class EmergencyAccessService : IEmergencyAccessService
_removeOrganizationUserCommand = removeOrganizationUserCommand;
}
public async Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime)
public async Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
{
if (!await _userService.CanAccessPremium(invitingUser))
if (!await _userService.CanAccessPremium(grantorUser))
{
throw new BadRequestException("Not a premium user.");
}
if (type == EmergencyAccessType.Takeover && invitingUser.UsesKeyConnector)
if (accessType == EmergencyAccessType.Takeover && grantorUser.UsesKeyConnector)
{
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
var emergencyAccess = new EmergencyAccess
{
GrantorId = invitingUser.Id,
Email = email.ToLowerInvariant(),
GrantorId = grantorUser.Id,
Email = emergencyContactEmail.ToLowerInvariant(),
Status = EmergencyAccessStatusType.Invited,
Type = type,
Type = accessType,
WaitTimeDays = waitTime,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await _emergencyAccessRepository.CreateAsync(emergencyAccess);
await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser));
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser));
return emergencyAccess;
}
public async Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid userId)
public async Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid grantorId)
{
var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, userId);
var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId);
if (emergencyAccess == null)
{
throw new BadRequestException("Emergency Access not valid.");
@ -98,19 +98,19 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
public async Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId)
public async Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.GrantorId != invitingUser.Id ||
if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.Invited)
{
throw new BadRequestException("Emergency Access not valid.");
}
await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser));
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser));
}
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService)
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null)
@ -123,7 +123,7 @@ public class EmergencyAccessService : IEmergencyAccessService
throw new BadRequestException("Invalid token.");
}
if (!data.IsValid(emergencyAccessId, user.Email))
if (!data.IsValid(emergencyAccessId, granteeUser.Email))
{
throw new BadRequestException("Invalid token.");
}
@ -140,7 +140,7 @@ public class EmergencyAccessService : IEmergencyAccessService
// TODO PM-21687
// Might not be reachable since the Tokenable.IsValid() does an email comparison
if (string.IsNullOrWhiteSpace(emergencyAccess.Email) ||
!emergencyAccess.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
!emergencyAccess.Email.Equals(granteeUser.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
@ -148,7 +148,7 @@ public class EmergencyAccessService : IEmergencyAccessService
var granteeEmail = emergencyAccess.Email;
emergencyAccess.Status = EmergencyAccessStatusType.Accepted;
emergencyAccess.GranteeId = user.Id;
emergencyAccess.GranteeId = granteeUser.Id;
emergencyAccess.Email = null;
var grantor = await userService.GetUserByIdAsync(emergencyAccess.GrantorId);
@ -172,16 +172,16 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.DeleteAsync(emergencyAccess);
}
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid confirmingUserId)
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
emergencyAccess.GrantorId != confirmingUserId)
emergencyAccess.GrantorId != grantorId)
{
throw new BadRequestException("Emergency Access not valid.");
}
var grantor = await _userRepository.GetByIdAsync(confirmingUserId);
var grantor = await _userRepository.GetByIdAsync(grantorId);
if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector)
{
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
@ -198,14 +198,14 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
public async Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser)
public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser)
{
if (!await _userService.CanAccessPremium(savingUser))
if (!await _userService.CanAccessPremium(grantorUser))
{
throw new BadRequestException("Not a premium user.");
}
if (emergencyAccess.GrantorId != savingUser.Id)
if (emergencyAccess.GrantorId != grantorUser.Id)
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -222,10 +222,11 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
}
public async Task InitiateAsync(Guid id, User initiatingUser)
// TODO PM-21687: rename this to something like InitiateRecoveryAsync, and something similar for Approve and Reject
public async Task InitiateAsync(Guid emergencyAccessId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
if (emergencyAccess == null || emergencyAccess.GranteeId != initiatingUser.Id ||
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.GranteeId != granteeUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.Confirmed)
{
throw new BadRequestException("Emergency Access not valid.");
@ -245,14 +246,14 @@ public class EmergencyAccessService : IEmergencyAccessService
emergencyAccess.LastNotificationDate = now;
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(initiatingUser), grantor.Email);
await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(granteeUser), grantor.Email);
}
public async Task ApproveAsync(Guid id, User approvingUser)
public async Task ApproveAsync(Guid emergencyAccessId, User grantorUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.GrantorId != approvingUser.Id ||
if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id ||
emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated)
{
throw new BadRequestException("Emergency Access not valid.");
@ -262,14 +263,14 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value);
await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(approvingUser), grantee.Email);
await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(grantorUser), grantee.Email);
}
public async Task RejectAsync(Guid id, User rejectingUser)
public async Task RejectAsync(Guid emergencyAccessId, User grantorUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.GrantorId != rejectingUser.Id ||
if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id ||
(emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated &&
emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved))
{
@ -280,17 +281,17 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value);
await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(rejectingUser), grantee.Email);
await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(grantorUser), grantee.Email);
}
public async Task<ICollection<Policy>> GetPoliciesAsync(Guid id, User requestingUser)
public async Task<ICollection<Policy>> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser)
{
// TODO PM-21687
// Should we look up policies here or just verify the EmergencyAccess is correct
// and handle policy logic else where? Should this be a query/Command?
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -306,11 +307,12 @@ public class EmergencyAccessService : IEmergencyAccessService
return policies;
}
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User requestingUser)
// TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -326,11 +328,12 @@ public class EmergencyAccessService : IEmergencyAccessService
return (emergencyAccess, grantor);
}
public async Task PasswordAsync(Guid id, User requestingUser, string newMasterPasswordHash, string key)
// TODO PM-21687: rename this to something like FinishRecoveryTakeoverAsync
public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover))
if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover))
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -392,11 +395,11 @@ public class EmergencyAccessService : IEmergencyAccessService
}
}
public async Task<EmergencyAccessViewData> ViewAsync(Guid id, User requestingUser)
public async Task<EmergencyAccessViewData> ViewAsync(Guid emergencyAccessId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View))
if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View))
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -410,11 +413,11 @@ public class EmergencyAccessService : IEmergencyAccessService
};
}
public async Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User requestingUser)
public async Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id);
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View))
if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View))
{
throw new BadRequestException("Emergency Access not valid.");
}
@ -429,18 +432,19 @@ public class EmergencyAccessService : IEmergencyAccessService
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
}
// TODO PM-21687: move this to the user entity -> User.GetNameOrEmail()?
private static string NameOrEmail(User user)
{
return string.IsNullOrWhiteSpace(user.Name) ? user.Email : user.Name;
}
/*
* Checks if EmergencyAccess Object is null
* Checks the requesting user is the same as the granteeUser (So we are checking for proper grantee action)
* Status _must_ equal RecoveryApproved (This means the grantor has invited, the grantee has accepted, and the grantor has approved so the shared key exists but hasn't been exercised yet)
* request type must equal the type of access requested (View or Takeover)
*/
//TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser
private static bool IsValidRequest(
EmergencyAccess availableAccess,
User requestingUser,

View File

@ -0,0 +1,147 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Services;
public interface IEmergencyAccessService
{
/// <summary>
/// Invites a user via email to become an emergency contact for the Grantor user. The Grantor must have a premium subscription.
/// the grantor user must not be a member of the organization that uses KeyConnector.
/// </summary>
/// <param name="grantorUser">The user initiating the emergency contact request</param>
/// <param name="emergencyContactEmail">Emergency contact</param>
/// <param name="accessType">Type of emergency access allowed to the emergency contact</param>
/// <param name="waitTime">The amount of time to pass before the invite is auto confirmed</param>
/// <returns>a new Emergency Access object</returns>
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
/// <summary>
/// Sends an invite to the emergency contact associated with the emergency access id.
/// </summary>
/// <param name="grantorUser">The grantor. This must be the owner of the Emergency Access object</param>
/// <param name="emergencyAccessId">The Id of the emergency access being requested.</param>
/// <returns>void</returns>
Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId);
/// <summary>
/// A grantee user accepts the emergency contact request. This updates the emergency access status to be
/// "Accepted", this is the middle step before the grantor user confirms the request.
/// </summary>
/// <param name="emergencyAccessId">Id of the emergency access object being acted on.</param>
/// <param name="granteeUser">User being invited to be an emergency contact</param>
/// <param name="token">the tokenable that was sent via email</param>
/// <param name="userService">service dependency</param>
/// <returns>void</returns>
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
/// <summary>
/// The creator of the emergency access request can delete the request.
/// </summary>
/// <param name="emergencyAccessId">Id of the emergency access being acted on</param>
/// <param name="grantorId">Id of the owner trying to delete the emergency access request</param>
/// <returns>void</returns>
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
/// <summary>
/// The grantor user confirms the acceptance of the emergency contact request. This stores the encrypted key allowing the grantee
/// access based on the emergency access type.
/// </summary>
/// <param name="emergencyAccessId">Id of the emergency access being acted on.</param>
/// <param name="key">The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key)</param>
/// <param name="grantorId">Id of grantor user</param>
/// <returns>emergency access object associated with the Id passed in</returns>
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
/// <summary>
/// Fetches an emergency access object. The grantor user must own the object being fetched.
/// </summary>
/// <param name="emergencyAccessId">Id of emergency access object</param>
/// <param name="grantorId">Id of the owner of the emergency access object.</param>
/// <returns>Details of the emergency access object</returns>
Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid grantorId);
/// <summary>
/// Updates the emergency access object.
/// </summary>
/// <param name="emergencyAccess">emergency access entity being updated</param>
/// <param name="grantorUser">grantor user</param>
/// <returns>void</returns>
Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser);
/// <summary>
/// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation.
/// </summary>
/// <param name="emergencyAccessId">EmergencyAccess Id</param>
/// <param name="granteeUser">grantee user</param>
/// <returns>void</returns>
Task InitiateAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Approves a recovery request. Sets the EmergencyAccess.Status to RecoveryApproved.
/// </summary>
/// <param name="emergencyAccessId">emergency access id</param>
/// <param name="grantorUser">grantor user</param>
/// <returns>void</returns>
Task ApproveAsync(Guid emergencyAccessId, User grantorUser);
/// <summary>
/// Rejects a recovery request. Sets the EmergencyAccess.Status to Confirmed. This does not remove the emergency access entity. The
/// Grantee user can still initiate another recovery request.
/// </summary>
/// <param name="emergencyAccessId">emergency access id</param>
/// <param name="grantorUser">grantor user</param>
/// <returns>void</returns>
Task RejectAsync(Guid emergencyAccessId, User grantorUser);
/// <summary>
/// This request is made by the Grantee user to fetch the policies <see cref="Policy"/> for the Grantor User.
/// The Grantor User has to be the owner of the organization. <see cref="OrganizationUserType"/>
/// If the Grantor user has OrganizationUserType.Owner then the policies for the _Grantor_ user
/// are returned. This is used to ensure the password is of the proper complexity for the organization.
/// </summary>
/// <param name="emergencyAccessId">EmergencyAccess.Id being acted on</param>
/// <param name="granteeUser">User making the request, this is the Grantee</param>
/// <returns>null if the GrantorUser is not an organization owner; A list of policies otherwise.</returns>
Task<ICollection<Policy>> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Fetches the emergency access entity and grantor user. The grantor user is returned so the correct KDF configuration is
/// used for the new password.
/// </summary>
/// <param name="emergencyAccessId">Id of entity being accessed</param>
/// <param name="granteeUser">grantee user of the emergency access entity</param>
/// <returns>emergency access entity and the grantorUser</returns>
Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Updates the grantor's password hash and updates the key for the EmergencyAccess entity.
/// </summary>
/// <param name="emergencyAccessId">Emergency Access Id being acted on</param>
/// <param name="granteeUser">user making the request</param>
/// <param name="newMasterPasswordHash">new password hash set by grantee user</param>
/// <param name="key">new encrypted user key</param>
/// <returns>void</returns>
Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key);
/// <summary>
/// sends a reminder email that there is a pending request for recovery.
/// </summary>
/// <returns>void</returns>
Task SendNotificationsAsync();
/// <summary>
/// This handles the auto approval of recovery requests started in the <see cref="InitiateAsync"/> method.
/// An email will be sent to the Grantee and the Grantor notifying each the recovery has been approved.
/// </summary>
/// <returns>void</returns>
Task HandleTimedOutRequestsAsync();
/// <summary>
/// Fetched ciphers from the grantors account for the grantee to view.
/// </summary>
/// <param name="emergencyAccessId">Emergency access entity being acted on</param>
/// <param name="granteeUser">user requesting cipher items</param>
/// <returns>ciphers associated with the emergency access request</returns>
Task<EmergencyAccessViewData> ViewAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Returns attachment if the grantee user has access to the cipher through the emergency access entity.
/// </summary>
/// <param name="emergencyAccessId">EmergencyAccess entity being acted on</param>
/// <param name="cipherId">cipher entity containing the attachment</param>
/// <param name="attachmentId">Attachment entity</param>
/// <param name="granteeUser">user making the request</param>
/// <returns>attachment response </returns>
Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser);
}

View File

@ -0,0 +1,95 @@
# Emergency Access System
This system allows users to share their `User.Key` with other users using public key exchange. An emergency contact (a grantee user) can view or takeover (reset the password) of the grantor user.
When an account is taken over all two factor methods are turned off and device verification is disabled.
This system is affected by the Key Rotation feature. The `EmergencyAccess.KeyEncrypted` is the `Grantor.UserKey` encrypted by the `Grantee.PublicKey`. So if the `User.Key` is rotated then all `EmergencyAccess` entities will need to be updated.
## Special Cases
Users who use `KeyConnector` are not able to allow `Takeover` of their accounts. However, they can allow `View`.
When a grantee user _takes over_ a grantor user's account, the grantor user will be removed from all organizations where the grantor user is not the `OrganizationUserType.Owner`. A grantor user will not be removed from organizations if the `EmergencyAccessType` is `View`. The grantee user will only be able to `View` the grantor user's ciphers, and not any of the organization ciphers, if any exist.
## Step 1. Invitation
A grantor user invites another user to be their emergency contact, the grantee. This will create a new `EmergencyAccess` entity in the database with the `EmergencyAccessStatusType` set to `Invited`.
The `EmergencyAccess.KeyEncrypted` field is empty, and the `GranteeId` is `null` since the user being invited via email may not have an account yet.
### code
```csharp
// creates entity.
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
// resend email to the EmergencyAccess.Email.
Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId);
```
## Step 2. Acceptance
The grantee user receives an email they have been invited to be an emergency contact for a grantor user.
At this point the grantee user can accept the request. This will set the `EmergencyAccess.GranteeId` to the `User.Id` of the grantee user. The `EmergencyAccess.Status` is set to `Accepted`.
If the grantee user does not have an account then they can create an account and accept the invitation.
### Code
```csharp
// accepts the request to be an emergency contact.
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
```
## Step 3. Confirmation
Once the grantee user has accepted, the `EmergencyAccess.GranteeId` allows the grantor user the ability to query for the `GranteeUser.PublicKey`. With the `Grantee.PublicKey`, the grantor on the client is able to safely encrypt their `User.Key` and save the encrypted string to the database.
The `EmergencyAccess.Status` is set to `Confirmed`, and the `EmergencyAccess.KeyEncrypted` is set.
### Code
```csharp
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
```
## Step 4. Recovery Approval
The grantee user can now exercise the ability to view or takeover the account. This is done by initiating the recovery. Initiating recovery has a time delay specified by `EmergencyAccess.WaitTime`. `WaitTime` is set in the initial invite. The grantor user can approve the request before the `WaitTime`, but they _cannot_ reject the request _after_ the `WaitTime` has completed. If the recovery request is not rejected then once the `WaitTime` has passed the grantee user will be able to access the emergency access entity.
### Code
```csharp
// Initiates the recovery process; Will set EmergencyAccess.Status to RecoveryInitiated.
Task InitiateAsync(Guid id, User granteeUser);
// Approved the recovery request; Will set EmergencyAccess.Status to RecoveryApproved.
Task ApproveAsync(Guid id, User approvingUser);
// Rejects the recovery request; Will set EmergencyAccess.Status to Confirmed.
Task RejectAsync(Guid id, User rejectingUser);
// Automatically set the EmergencyAccess.Status to RecoveryApproved after WaitTime has passed.
Task HandleTimedOutRequestsAsync();
```
## Step 5. Recovering the account
Once the `EmergencyAccess.Status` is `RecoveryApproved` the grantee user is able to exercise their ability to view or takeover the grantor account. Viewing allows the grantee user to view the vault data of the grantor user. Takeover allows the grantee to change the password of the grantor user.
### Takeover
`TakeoverAsync(Guid, User)` returns the grantor user object along with the `EmergencyAccess` entity. The grantor user object is required since to update the password the client needs access to the grantor kdf configuration. Once the password has been set in the `PasswordAsync(Guid, User, string, string)` the account has been successfully recovered.
Taking over the account will change the password of the grantor user, empty the two factor array on the grantor user, and disable device verification.
```csharp
// Takeover returns the grantor user and the emergency access entity.
Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
// Password sets the password for the grantor user.
Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key);
// Returns Ciphers the Grantee is allowed to view based on the EmergencyAccess status.
Task<EmergencyAccessViewData> ViewAsync(Guid emergencyAccessId, User granteeUser);
// Returns downloadable cipher attachments based on the EmergencyAccess status.
Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser);
```
## Optional steps
The grantor user is able to delete an emergency contact at anytime, at any point in the recovery process.
### Code
```csharp
// deletes the associated EmergencyAccess Entity
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
```

View File

@ -1,40 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Services;
public interface IEmergencyAccessService
{
Task<EmergencyAccess> InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime);
Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId);
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService);
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
Task<EmergencyAccessDetails> GetAsync(Guid emergencyAccessId, Guid userId);
Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser);
Task InitiateAsync(Guid id, User initiatingUser);
Task ApproveAsync(Guid id, User approvingUser);
Task RejectAsync(Guid id, User rejectingUser);
/// <summary>
/// This request is made by the Grantee user to fetch the policies <see cref="Policy"/> for the Grantor User.
/// The Grantor User has to be the owner of the organization. <see cref="OrganizationUserType"/>
/// If the Grantor user has OrganizationUserType.Owner then the policies for the _Grantor_ user
/// are returned.
/// </summary>
/// <param name="id">EmergencyAccess.Id being acted on</param>
/// <param name="requestingUser">User making the request, this is the Grantee</param>
/// <returns>null if the GrantorUser is not an organization owner; A list of policies otherwise.</returns>
Task<ICollection<Policy>> GetPoliciesAsync(Guid id, User requestingUser);
Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User initiatingUser);
Task PasswordAsync(Guid id, User user, string newMasterPasswordHash, string key);
Task SendNotificationsAsync();
Task HandleTimedOutRequestsAsync();
Task<EmergencyAccessViewData> ViewAsync(Guid id, User user);
Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User user);
}

View File

@ -202,6 +202,7 @@ public static class FeatureFlagKeys
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";
public static List<string> GetAllKeys()
{

View File

@ -34,9 +34,9 @@
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.12.0" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.49.0" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.51.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />

View File

@ -36,6 +36,11 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
public string? TwoFactorRecoveryCode { get; set; }
public string? EquivalentDomains { get; set; }
public string? ExcludedGlobalEquivalentDomains { get; set; }
/// <summary>
/// The Account Revision Date is used to check if new sync needs to occur. It should be updated
/// whenever a change is made that affects a client's sync data; for example, updating their vault or
/// organization membership.
/// </summary>
public DateTime AccountRevisionDate { get; set; } = DateTime.UtcNow;
public string? Key { get; set; }
public string? PublicKey { get; set; }

View File

@ -312,11 +312,19 @@ public class GlobalSettings : IGlobalSettings
private string _hostName;
private string _username;
private string _password;
private string _exchangeName;
private string _eventExchangeName;
private string _integrationExchangeName;
public int MaxRetries { get; set; } = 3;
public int RetryTiming { get; set; } = 30000; // 30s
public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
public virtual string WebhookQueueName { get; set; } = "events-webhook-queue";
public virtual string SlackQueueName { get; set; } = "events-slack-queue";
public virtual string IntegrationDeadLetterQueueName { get; set; } = "integration-dead-letter-queue";
public virtual string SlackEventsQueueName { get; set; } = "events-slack-queue";
public virtual string SlackIntegrationQueueName { get; set; } = "integration-slack-queue";
public virtual string SlackIntegrationRetryQueueName { get; set; } = "integration-slack-retry-queue";
public virtual string WebhookEventsQueueName { get; set; } = "events-webhook-queue";
public virtual string WebhookIntegrationQueueName { get; set; } = "integration-webhook-queue";
public virtual string WebhookIntegrationRetryQueueName { get; set; } = "integration-webhook-retry-queue";
public string HostName
{
@ -333,10 +341,15 @@ public class GlobalSettings : IGlobalSettings
get => _password;
set => _password = value.Trim('"');
}
public string ExchangeName
public string EventExchangeName
{
get => _exchangeName;
set => _exchangeName = value.Trim('"');
get => _eventExchangeName;
set => _eventExchangeName = value.Trim('"');
}
public string IntegrationExchangeName
{
get => _integrationExchangeName;
set => _integrationExchangeName = value.Trim('"');
}
}
}

View File

@ -60,9 +60,21 @@ public class Send : ITableObject<Guid>
/// <summary>
/// Password provided by the user. Protected with pbkdf2.
/// </summary>
/// <remarks>
/// This field is mutually exclusive with <see cref="Emails" />
/// </remarks>
[MaxLength(300)]
public string? Password { get; set; }
/// <summary>
/// Comma-separated list of emails for OTP authentication.
/// </summary>
/// <remarks>
/// This field is mutually exclusive with <see cref="Password" />
/// </remarks>
[MaxLength(1024)]
public string? Emails { get; set; }
/// <summary>
/// The send becomes unavailable to API callers when
/// <see cref="AccessCount"/> &gt;= <see cref="MaxAccessCount"/>.

View File

@ -115,7 +115,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
}
// Create it all
await _cipherRepository.CreateAsync(ciphers, newFolders);
await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders);
// push
await _pushService.PushSyncVaultAsync(importingUserId);

View File

@ -0,0 +1,50 @@
#nullable enable
namespace Bit.Core.Tools.Models.Data;
/// <summary>
/// A discriminated union for send authentication.
/// </summary>
/// <example>
/// const method : SendAuthenticationMethod;
/// // other variable definitions omitted
///
/// var token = method switch
/// {
/// NotAuthenticated => issueTokenFor(sendId),
/// ResourcePassword(var expected) => tryIssueTokenFor(sendId, expected, actual),
/// EmailOtp(_) => tryIssueTokenFor(sendId, email, actualOtp),
/// _ => throw new Exception()
/// };
/// </example>
public abstract record SendAuthenticationMethod;
/// <summary>
/// Never issue a send claim.
/// </summary>
/// <remarks>
/// This claim is issued when a send does not exist or when a send
/// has exceeded its max access attempts.
/// </remarks>
public record NeverAuthenticate : SendAuthenticationMethod;
/// <summary>
/// Create a send claim automatically.
/// </summary>
public record NotAuthenticated : SendAuthenticationMethod;
/// <summary>
/// Create a send claim by requesting a password confirmation hash.
/// </summary>
/// <param name="Hash">
/// A base64 encoded hash that permits access to the send.
/// </param>
public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// <summary>
/// Create a send claim by requesting a one time password (OTP) confirmation code.
/// </summary>
/// <param name="Emails">
/// The list of email addresses permitted access to the send.
/// </param>
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;

View File

@ -0,0 +1,20 @@
using Bit.Core.Tools.Models.Data;
#nullable enable
namespace Bit.Core.Tools.SendFeatures.Queries.Interfaces;
/// <summary>
/// Integration with authentication layer for generating send access claims.
/// </summary>
public interface ISendAuthenticationQuery
{
/// <summary>
/// Retrieves the authentication method of a Send.
/// </summary>
/// <param name="sendId">Identifies the send to inspect.</param>
/// <returns>
/// The authentication method that should be performed for the send.
/// </returns>
Task<SendAuthenticationMethod> GetAuthenticationMethod(Guid sendId);
}

View File

@ -0,0 +1,53 @@
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
#nullable enable
namespace Bit.Core.Tools.SendFeatures.Queries;
/// <inheritdoc cref="ISendAuthenticationQuery"/>
public class SendAuthenticationQuery : ISendAuthenticationQuery
{
private static readonly NotAuthenticated NOT_AUTHENTICATED = new NotAuthenticated();
private static readonly NeverAuthenticate NEVER_AUTHENTICATE = new NeverAuthenticate();
private readonly ISendRepository _sendRepository;
/// <summary>
/// Instantiates the command
/// </summary>
/// <param name="sendRepository">
/// Retrieves send records
/// </param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
/// </exception>
public SendAuthenticationQuery(ISendRepository sendRepository)
{
_sendRepository = sendRepository ?? throw new ArgumentNullException(nameof(sendRepository));
}
/// <inheritdoc cref="ISendAuthenticationQuery.GetAuthenticationMethod"/>
public async Task<SendAuthenticationMethod> GetAuthenticationMethod(Guid sendId)
{
var send = await _sendRepository.GetByIdAsync(sendId);
SendAuthenticationMethod method = send switch
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.Emails is not null => emailOtp(s.Emails),
var s when s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
return method;
}
private EmailOtp emailOtp(string emails)
{
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}

View File

@ -1,5 +1,7 @@
using Bit.Core.Tools.SendFeatures.Commands;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.SendFeatures.Queries;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Tools.Services;
using Microsoft.Extensions.DependencyInjection;
@ -14,5 +16,6 @@ public static class SendServiceCollectionExtension
services.AddScoped<ISendAuthorizationService, SendAuthorizationService>();
services.AddScoped<ISendValidationService, SendValidationService>();
services.AddScoped<ISendCoreHelperService, SendCoreHelperService>();
services.AddScoped<ISendAuthenticationQuery, SendAuthenticationQuery>();
}
}

View File

@ -32,7 +32,10 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
Task DeleteByUserIdAsync(Guid userId);
Task DeleteByOrganizationIdAsync(Guid organizationId);
Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
/// <summary>
/// Create ciphers and folders for the specified UserId. Must not be used to create organization owned items.
/// </summary>
Task CreateAsync(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);

View File

@ -1,6 +1,4 @@
using System.Globalization;
using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Context;
using Bit.Core.IdentityServer;
using Bit.Core.Services;
@ -63,37 +61,7 @@ public class Startup
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
}
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{
services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else
{
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{
services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
services.AddScoped<IEventWriteService, EventRouteService>();
services.AddEventWriteServices(globalSettings);
services.AddScoped<IEventService, EventService>();
services.AddOptionality();
@ -109,49 +77,7 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Optional RabbitMQ Listeners
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{
services.AddSingleton<EventRepositoryHandler>();
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<EventRepositoryHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.SlackQueueName));
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<WebhookEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.WebhookQueueName));
}
services.AddRabbitMqListeners(globalSettings);
}
public void Configure(

View File

@ -1,12 +1,8 @@
using System.Globalization;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.IdentityModel.Logging;
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
namespace Bit.EventsProcessor;
@ -37,50 +33,7 @@ public class Startup
services.AddDatabaseRepositories(globalSettings);
// Hosted Services
// Optional Azure Service Bus Listeners
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{
services.AddSingleton<IEventRepository, TableStorageRepos.EventRepository>();
services.AddSingleton<AzureTableStorageEventHandler>();
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<AzureTableStorageEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName));
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.SlackSubscriptionName));
services.AddSingleton<WebhookEventHandler>();
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName));
}
services.AddAzureServiceBusListeners(globalSettings);
services.AddHostedService<AzureQueueHostedService>();
}

View File

@ -484,7 +484,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
}
}
public async Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders)
public async Task CreateAsync(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders)
{
if (!ciphers.Any())
{
@ -518,7 +518,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
await connection.ExecuteAsync(
$"[{Schema}].[User_BumpAccountRevisionDate]",
new { Id = ciphers.First().UserId },
new { Id = userId },
commandType: CommandType.StoredProcedure, transaction: transaction);
transaction.Commit();

View File

@ -1,4 +1,5 @@
using AutoMapper;
using System.Diagnostics;
using AutoMapper;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
@ -7,11 +8,12 @@ using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Infrastructure.EntityFramework.Repositories;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
public class OrganizationUserRepository : Repository<Core.Entities.OrganizationUser, OrganizationUser, Guid>, IOrganizationUserRepository
{
@ -440,15 +442,23 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
}
}
public async override Task ReplaceAsync(Core.Entities.OrganizationUser organizationUser)
public override async Task ReplaceAsync(Core.Entities.OrganizationUser organizationUser)
{
await base.ReplaceAsync(organizationUser);
using (var scope = ServiceScopeFactory.CreateScope())
// Only bump account revision dates for confirmed OrgUsers,
// as this is the only status that receives sync data from the organization
if (organizationUser.Status is not OrganizationUserStatusType.Confirmed)
{
var dbContext = GetDatabaseContext(scope);
await dbContext.UserBumpAccountRevisionDateAsync(organizationUser.UserId.GetValueOrDefault());
await dbContext.SaveChangesAsync();
return;
}
Debug.Assert(organizationUser.UserId is not null, "OrganizationUser is confirmed but does not have a UserId.");
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
await dbContext.UserBumpAccountRevisionDateAsync(organizationUser.UserId.Value);
await dbContext.SaveChangesAsync();
}
public async Task ReplaceAsync(Core.Entities.OrganizationUser obj, IEnumerable<CollectionAccessSelection> requestedCollections)

View File

@ -1,4 +1,6 @@
using System.Diagnostics;
#nullable enable
using System.Diagnostics;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Enums;
@ -11,8 +13,18 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
public static class DatabaseContextExtensions
{
/// <summary>
/// Bump the account revision date for the user.
/// The caller is responsible for providing a valid UserId (not a null or default Guid) for a user that exists
/// in the database.
/// </summary>
public static async Task UserBumpAccountRevisionDateAsync(this DatabaseContext context, Guid userId)
{
if (userId == Guid.Empty)
{
throw new ArgumentException("Invalid UserId.");
}
var user = await context.Users.FindAsync(userId);
Debug.Assert(user is not null, "The user id is expected to be validated as a true-in database user before making this call.");
user.AccountRevisionDate = DateTime.UtcNow;

View File

@ -142,8 +142,10 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
}
}
public async Task CreateAsync(IEnumerable<Core.Vault.Entities.Cipher> ciphers, IEnumerable<Core.Vault.Entities.Folder> folders)
public async Task CreateAsync(Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers,
IEnumerable<Core.Vault.Entities.Folder> folders)
{
ciphers = ciphers.ToList();
if (!ciphers.Any())
{
return;
@ -156,7 +158,8 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, folderEntities);
var cipherEntities = Mapper.Map<List<Cipher>>(ciphers);
await dbContext.BulkCopyAsync(base.DefaultBulkCopyOptions, cipherEntities);
await dbContext.UserBumpAccountRevisionDateAsync(ciphers.First().UserId.GetValueOrDefault());
await dbContext.UserBumpAccountRevisionDateAsync(userId);
await dbContext.SaveChangesAsync();
}
}

View File

@ -5,6 +5,7 @@ using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit;
using Azure.Storage.Queues;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@ -324,42 +325,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IMailEnqueuingService, BlockingMailEnqueuingService>();
}
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{
services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else if (globalSettings.SelfHosted)
{
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{
services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
services.AddScoped<IEventWriteService, EventRouteService>();
services.AddEventWriteServices(globalSettings);
if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString))
{
@ -584,6 +550,193 @@ public static class ServiceCollectionExtensions
return globalSettings;
}
public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings)
{
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{
services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else if (globalSettings.SelfHosted)
{
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (IsRabbitMqEnabled(globalSettings))
{
services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
}
else
{
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
}
services.AddScoped<IEventWriteService, EventRouteService>();
return services;
}
public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{
services.AddSingleton<IEventRepository, TableStorageRepos.EventRepository>();
services.AddSingleton<AzureTableStorageEventHandler>();
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<AzureTableStorageEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName));
services.AddSlackService(globalSettings);
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.SlackSubscriptionName));
services.AddSingleton<WebhookEventHandler>();
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName));
}
return services;
}
public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings)
{
if (IsRabbitMqEnabled(globalSettings))
{
services.AddRabbitMqEventRepositoryListener(globalSettings);
services.AddSlackService(globalSettings);
services.AddRabbitMqIntegration<SlackIntegrationConfigurationDetails, SlackIntegrationHandler>(
globalSettings.EventLogging.RabbitMq.SlackEventsQueueName,
globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName,
globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName,
globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
IntegrationType.Slack,
globalSettings);
services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookIntegrationHandler>(
globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName,
globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName,
globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName,
globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName,
IntegrationType.Webhook,
globalSettings);
}
return services;
}
public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
return services;
}
private static IServiceCollection AddRabbitMqEventRepositoryListener(this IServiceCollection services, GlobalSettings globalSettings)
{
services.AddSingleton<EventRepositoryHandler>();
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<EventRepositoryHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
return services;
}
private static IServiceCollection AddRabbitMqIntegration<TConfig, THandler>(this IServiceCollection services,
string eventQueueName,
string integrationQueueName,
string integrationRetryQueueName,
string integrationDeadLetterQueueName,
IntegrationType integrationType,
GlobalSettings globalSettings)
where TConfig : class
where THandler : class, IIntegrationHandler<TConfig>
{
var routingKey = integrationType.ToRoutingKey();
services.AddSingleton<IIntegrationPublisher, RabbitMqIntegrationPublisher>();
services.AddKeyedSingleton<IEventMessageHandler>(routingKey, (provider, _) =>
new EventIntegrationHandler<TConfig>(
integrationType,
provider.GetRequiredService<IIntegrationPublisher>(),
provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
provider.GetRequiredService<IUserRepository>(),
provider.GetRequiredService<IOrganizationRepository>()));
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredKeyedService<IEventMessageHandler>(routingKey),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
eventQueueName));
services.AddSingleton<IIntegrationHandler<TConfig>, THandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqIntegrationListenerService(
handler: provider.GetRequiredService<IIntegrationHandler<TConfig>>(),
routingKey: routingKey,
queueName: integrationQueueName,
retryQueueName: integrationRetryQueueName,
deadLetterQueueName: integrationDeadLetterQueueName,
globalSettings: globalSettings,
logger: provider.GetRequiredService<ILogger<RabbitMqIntegrationListenerService>>()));
return services;
}
private static bool IsRabbitMqEnabled(GlobalSettings settings)
{
return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName);
}
public static void UseDefaultMiddleware(this IApplicationBuilder app,
IWebHostEnvironment env, GlobalSettings globalSettings)
{

View File

@ -2,7 +2,7 @@
@Id BIGINT OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@ExternalId NVARCHAR(50),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7)
AS
BEGIN

View File

@ -2,7 +2,7 @@
@Id BIGINT OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@ExternalId NVARCHAR(50),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7)
AS
BEGIN

View File

@ -2,7 +2,7 @@
[Id] BIGINT IDENTITY (1, 1) NOT NULL,
[UserId] UNIQUEIDENTIFIER NOT NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL,
[ExternalId] NVARCHAR(50) NOT NULL,
[ExternalId] NVARCHAR(300) NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_SsoUser] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_SsoUser_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,

View File

@ -14,7 +14,10 @@
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL
@CipherId UNIQUEIDENTIFIER = NULL,
-- FIXME: remove null default value once this argument has been
-- in 2 server releases
@Emails NVARCHAR(1024) = NULL
AS
BEGIN
SET NOCOUNT ON
@ -36,7 +39,8 @@ BEGIN
[DeletionDate],
[Disabled],
[HideEmail],
[CipherId]
[CipherId],
[Emails]
)
VALUES
(
@ -55,7 +59,8 @@ BEGIN
@DeletionDate,
@Disabled,
@HideEmail,
@CipherId
@CipherId,
@Emails
)
IF @UserId IS NOT NULL

View File

@ -14,7 +14,8 @@
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL
AS
BEGIN
SET NOCOUNT ON
@ -36,7 +37,8 @@ BEGIN
[DeletionDate] = @DeletionDate,
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId
[CipherId] = @CipherId,
[Emails] = @Emails
WHERE
[Id] = @Id

View File

@ -6,6 +6,7 @@
[Data] VARCHAR(MAX) NOT NULL,
[Key] VARCHAR (MAX) NOT NULL,
[Password] NVARCHAR (300) NULL,
[Emails] NVARCHAR (1024) NULL,
[MaxAccessCount] INT NULL,
[AccessCount] INT NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,

View File

@ -3,10 +3,10 @@ using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;

View File

@ -1,7 +1,7 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;

View File

@ -0,0 +1,53 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Xunit;
namespace Bit.Core.Test.Models.Data.Integrations;
public class IntegrationMessageTests
{
[Fact]
public void ApplyRetry_IncrementsRetryCountAndSetsDelayUntilDate()
{
var message = new IntegrationMessage<WebhookIntegrationConfigurationDetails>
{
RetryCount = 2,
DelayUntilDate = null
};
var baseline = DateTime.UtcNow;
message.ApplyRetry(baseline);
Assert.Equal(3, message.RetryCount);
Assert.True(message.DelayUntilDate > baseline);
}
[Fact]
public void FromToJson_SerializesCorrectly()
{
var message = new IntegrationMessage<WebhookIntegrationConfigurationDetails>
{
Configuration = new WebhookIntegrationConfigurationDetails("https://localhost"),
RenderedTemplate = "This is the message",
IntegrationType = IntegrationType.Webhook,
RetryCount = 2,
DelayUntilDate = null
};
var json = message.ToJson();
var result = IntegrationMessage<WebhookIntegrationConfigurationDetails>.FromJson(json);
Assert.Equal(message.Configuration, result.Configuration);
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);
Assert.Equal(message.IntegrationType, result.IntegrationType);
Assert.Equal(message.RetryCount, result.RetryCount);
}
[Fact]
public void FromJson_InvalidJson_ThrowsJsonException()
{
var json = "{ Invalid JSON";
Assert.Throws<JsonException>(() => IntegrationMessage<WebhookIntegrationConfigurationDetails>.FromJson(json));
}
}

View File

@ -0,0 +1,212 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class EventIntegrationHandlerTests
{
private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#";
private const string _templateWithOrganization = "Org: #OrganizationName#";
private const string _templateWithUser = "#UserName#, #UserEmail#";
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#";
private const string _url = "https://localhost";
private const string _url2 = "https://example.com";
private readonly IIntegrationPublisher _integrationPublisher = Substitute.For<IIntegrationPublisher>();
private SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> configurations)
{
var configurationRepository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
configurationRepository.GetConfigurationDetailsAsync(Arg.Any<Guid>(),
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
return new SutProvider<EventIntegrationHandler<WebhookIntegrationConfigurationDetails>>()
.SetDependency(configurationRepository)
.SetDependency(_integrationPublisher)
.SetDependency(IntegrationType.Webhook)
.Create();
}
private static IntegrationMessage<WebhookIntegrationConfigurationDetails> expectedMessage(string template)
{
return new IntegrationMessage<WebhookIntegrationConfigurationDetails>()
{
IntegrationType = IntegrationType.Webhook,
Configuration = new WebhookIntegrationConfigurationDetails(_url),
RenderedTemplate = template,
RetryCount = 0,
DelayUntilDate = null
};
}
private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations()
{
return [];
}
private static List<OrganizationIntegrationConfigurationDetails> OneConfiguration(string template)
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = null;
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url });
config.Template = template;
return [config];
}
private static List<OrganizationIntegrationConfigurationDetails> TwoConfigurations(string template)
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = null;
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url });
config.Template = template;
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config2.Configuration = null;
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url2 });
config2.Template = template;
return [config, config2];
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Empty(_integrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedMessage = EventIntegrationHandlerTests.expectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
Assert.Single(_integrationPublisher.ReceivedCalls());
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var user = Substitute.For<User>();
user.Email = "test@example.com";
user.Name = "Test";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedMessage = EventIntegrationHandlerTests.expectedMessage($"{user.Name}, {user.Email}");
Assert.Single(_integrationPublisher.ReceivedCalls());
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var organization = Substitute.For<Organization>();
organization.Name = "Test";
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization);
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Single(_integrationPublisher.ReceivedCalls());
var expectedMessage = EventIntegrationHandlerTests.expectedMessage($"Org: {organization.Name}");
Assert.Single(_integrationPublisher.ReceivedCalls());
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty);
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var user = Substitute.For<User>();
user.Email = "test@example.com";
user.Name = "Test";
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedMessage = EventIntegrationHandlerTests.expectedMessage($"{user.Name}, {user.Email}");
Assert.Single(_integrationPublisher.ReceivedCalls());
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty);
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
Assert.Empty(_integrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
foreach (var eventMessage in eventMessages)
{
var expectedMessage = EventIntegrationHandlerTests.expectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
}
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes(
List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
foreach (var eventMessage in eventMessages)
{
var expectedMessage = EventIntegrationHandlerTests.expectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_url2);
await _integrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expectedMessage)));
}
}
}

View File

@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Enums;
using Bit.Core.Services;
using Xunit;
namespace Bit.Core.Test.Services;
public class IntegrationHandlerTests
{
[Fact]
public async Task HandleAsync_ConvertsJsonToTypedIntegrationMessage()
{
var sut = new TestIntegrationHandler();
var expected = new IntegrationMessage<WebhookIntegrationConfigurationDetails>()
{
Configuration = new WebhookIntegrationConfigurationDetails("https://localhost"),
IntegrationType = IntegrationType.Webhook,
RenderedTemplate = "Template",
DelayUntilDate = null,
RetryCount = 0
};
var result = await sut.HandleAsync(expected.ToJson());
var typedResult = Assert.IsType<IntegrationMessage<WebhookIntegrationConfigurationDetails>>(result.Message);
Assert.Equal(expected.Configuration, typedResult.Configuration);
Assert.Equal(expected.RenderedTemplate, typedResult.RenderedTemplate);
Assert.Equal(expected.IntegrationType, typedResult.IntegrationType);
}
private class TestIntegrationHandler : IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
{
public override Task<IntegrationHandlerResult> HandleAsync(
IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var result = new IntegrationHandlerResult(success: true, message: message);
return Task.FromResult(result);
}
}
}

View File

@ -0,0 +1,30 @@
using Bit.Core.Enums;
using Xunit;
namespace Bit.Core.Test.Services;
public class IntegrationTypeTests
{
[Fact]
public void ToRoutingKey_Slack_Succeeds()
{
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Webhook_Succeeds()
{
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_CloudBillingSync_ThrowsException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => IntegrationType.CloudBillingSync.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Scim_ThrowsException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => IntegrationType.Scim.ToRoutingKey());
}
}

View File

@ -0,0 +1,42 @@
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class SlackIntegrationHandlerTests
{
private readonly ISlackService _slackService = Substitute.For<ISlackService>();
private readonly string _channelId = "C12345";
private readonly string _token = "xoxb-test-token";
private SutProvider<SlackIntegrationHandler> GetSutProvider()
{
return new SutProvider<SlackIntegrationHandler>()
.SetDependency(_slackService)
.Create();
}
[Theory, BitAutoData]
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
}

View File

@ -0,0 +1,139 @@
using System.Net;
using Bit.Core.AdminConsole.Models.Data.Integrations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Bit.Test.Common.MockedHttpClient;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class WebhookIntegrationHandlerTests
{
private readonly MockedHttpMessageHandler _handler;
private readonly HttpClient _httpClient;
private const string _webhookUrl = "http://localhost/test/event";
public WebhookIntegrationHandlerTests()
{
_handler = new MockedHttpMessageHandler();
_handler.Fallback
.WithStatusCode(HttpStatusCode.OK)
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
_httpClient = _handler.ToHttpClient();
}
private SutProvider<WebhookIntegrationHandler> GetSutProvider()
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(WebhookIntegrationHandler.HttpClientName).Returns(_httpClient);
return new SutProvider<WebhookIntegrationHandler>()
.SetDependency(clientFactory)
.Create();
}
[Theory, BitAutoData]
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
Assert.Equal(result.Message, message);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName))
);
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
var returned = await request.Content.ReadAsStringAsync();
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl, request.RequestUri.ToString());
AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned);
}
[Theory, BitAutoData]
public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsNotBeforUtc(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback
.WithStatusCode(HttpStatusCode.TooManyRequests)
.WithHeader("Retry-After", "60")
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.Equal(result.Message, message);
Assert.True(result.DelayUntilDate.HasValue);
Assert.InRange(result.DelayUntilDate.Value, DateTime.UtcNow.AddSeconds(59), DateTime.UtcNow.AddSeconds(61));
}
[Theory, BitAutoData]
public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsNotBeforUtc(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback
.WithStatusCode(HttpStatusCode.TooManyRequests)
.WithHeader("Retry-After", DateTime.UtcNow.AddSeconds(60).ToString("r")) // "r" is the round-trip format: RFC1123
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.Equal(result.Message, message);
Assert.True(result.DelayUntilDate.HasValue);
Assert.InRange(result.DelayUntilDate.Value, DateTime.UtcNow.AddSeconds(59), DateTime.UtcNow.AddSeconds(61));
}
[Theory, BitAutoData]
public async Task HandleAsync_InternalServerError_ReturnsFailureSetsRetryable(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback
.WithStatusCode(HttpStatusCode.InternalServerError)
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.Equal(result.Message, message);
Assert.False(result.DelayUntilDate.HasValue);
}
[Theory, BitAutoData]
public async Task HandleAsync_UnexpectedRedirect_ReturnsFailureNotRetryable(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback
.WithStatusCode(HttpStatusCode.TemporaryRedirect)
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal(result.Message, message);
Assert.Null(result.DelayUntilDate);
}
}

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Utilities;
#nullable enable
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Models.Data;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
@ -76,18 +78,6 @@ public class IntegrationTemplateProcessorTests
var expectedEmpty = "";
Assert.Equal(expectedEmpty, IntegrationTemplateProcessor.ReplaceTokens(emptyTemplate, eventMessage));
Assert.Null(IntegrationTemplateProcessor.ReplaceTokens(null, eventMessage));
}
[Fact]
public void ReplaceTokens_DataObjectIsNull_ReturnsOriginalString()
{
var template = "Event #Type#, User (id: #UserId#).";
var expected = "Event #Type#, User (id: #UserId#).";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, null);
Assert.Equal(expected, result);
}
[Theory]

View File

@ -49,7 +49,7 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
// Assert
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
@ -77,7 +77,7 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}

View File

@ -0,0 +1,135 @@
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.Services;
public class SendAuthenticationQueryTests
{
private readonly ISendRepository _sendRepository;
private readonly SendAuthenticationQuery _sendAuthenticationQuery;
public SendAuthenticationQueryTests()
{
_sendRepository = Substitute.For<ISendRepository>();
_sendAuthenticationQuery = new SendAuthenticationQuery(_sendRepository);
}
[Fact]
public void Constructor_WithNullRepository_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() => new SendAuthenticationQuery(null));
Assert.Equal("sendRepository", exception.ParamName);
}
[Theory]
[MemberData(nameof(AuthenticationMethodTestCases))]
public async Task GetAuthenticationMethod_ReturnsExpectedAuthenticationMethod(Send? send, Type expectedType)
{
// Arrange
var sendId = Guid.NewGuid();
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType(expectedType, result);
}
[Theory]
[MemberData(nameof(EmailParsingTestCases))]
public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails)
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
var emailOtp = Assert.IsType<EmailOtp>(result);
Assert.Equal(expectedEmails, emailOtp.Emails);
}
[Fact]
public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp()
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword");
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<EmailOtp>(result);
}
[Fact]
public async Task GetAuthenticationMethod_CallsRepositoryWithCorrectSendId()
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Fact]
public async Task GetAuthenticationMethod_WhenRepositoryThrows_PropagatesException()
{
// Arrange
var sendId = Guid.NewGuid();
var expectedException = new InvalidOperationException("Repository error");
_sendRepository.GetByIdAsync(sendId).Returns(Task.FromException<Send?>(expectedException));
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sendAuthenticationQuery.GetAuthenticationMethod(sendId));
Assert.Same(expectedException, exception);
}
public static IEnumerable<object[]> AuthenticationMethodTestCases()
{
yield return new object[] { null, typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null), typeof(EmailOtp) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword"), typeof(ResourcePassword) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null), typeof(NotAuthenticated) };
}
public static IEnumerable<object[]> EmailParsingTestCases()
{
yield return new object[] { "test@example.com", new[] { "test@example.com" } };
yield return new object[] { "test1@example.com,test2@example.com", new[] { "test1@example.com", "test2@example.com" } };
yield return new object[] { " test@example.com , other@example.com ", new[] { "test@example.com", "other@example.com" } };
yield return new object[] { "test@example.com,,other@example.com", new[] { "test@example.com", "other@example.com" } };
yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } };
}
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password)
{
return new Send
{
Id = Guid.NewGuid(),
AccessCount = accessCount,
MaxAccessCount = maxAccessCount,
Emails = emails,
Password = password
};
}
}

View File

@ -28,4 +28,31 @@ public class StaticStoreTests
Assert.NotNull(plan);
Assert.Equal(planType, plan.Type);
}
[Fact]
public void StaticStore_GlobalEquivalentDomains_OnlyAsciiAllowed()
{
// Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/
// URLs can contain unicode characters that to a computer would point to completely seperate domains but to the
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
// url update that could be missed in code review and then if they got a user to that URL Bitwarden could
// consider it equivalent with a cipher in the users vault and offer autofill when we should not.
// GitHub does now show a warning on non-ascii characters but it could still be missed.
// https://github.blog/changelog/2025-05-01-github-now-provides-a-warning-about-hidden-unicode-text/
// To defend against this:
// Loop through all equivalent domains and fail if any contain a non-ascii character
// non-ascii character can make a valid URL so it's possible that in the future we have a domain
// we want to allow list, that should be done through `continue`ing in the below foreach loop
// only if the domain strictly equals (do NOT use InvariantCulture comparison) the one added to our allow list.
foreach (var domain in StaticStore.GlobalDomains.SelectMany(p => p.Value))
{
for (var i = 0; i < domain.Length; i++)
{
var character = domain[i];
Assert.True(char.IsAscii(character), $"Domain: {domain} contains non-ASCII character: '{character}' at index: {i}");
}
}
}
}

View File

@ -7,6 +7,7 @@ using Bit.Core.Test.AutoFixture.Attributes;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Xunit;
using EfRepo = Bit.Infrastructure.EntityFramework.Repositories;
using Organization = Bit.Core.AdminConsole.Entities.Organization;
@ -161,7 +162,7 @@ public class OrganizationRepositoryTests
[CiSkippedTheory, EfOrganizationUserAutoData]
public async Task SearchUnassignedAsync_Works(OrganizationUser orgUser, User user, Organization org,
List<EfRepo.OrganizationUserRepository> efOrgUserRepos, List<EfRepo.OrganizationRepository> efOrgRepos, List<EfRepo.UserRepository> efUserRepos,
List<OrganizationUserRepository> efOrgUserRepos, List<EfRepo.OrganizationRepository> efOrgRepos, List<EfRepo.UserRepository> efUserRepos,
SqlRepo.OrganizationUserRepository sqlOrgUserRepo, SqlRepo.OrganizationRepository sqlOrgRepo, SqlRepo.UserRepository sqlUserRepo)
{
orgUser.Type = OrganizationUserType.Owner;

View File

@ -24,7 +24,7 @@ public class OrganizationUserRepositoryTests
{
[CiSkippedTheory, EfOrganizationUserAutoData]
public async Task CreateAsync_Works_DataMatches(OrganizationUser orgUser, User user, Organization org,
OrganizationUserCompare equalityComparer, List<EfRepo.OrganizationUserRepository> suts,
OrganizationUserCompare equalityComparer, List<EfAdminConsoleRepo.OrganizationUserRepository> suts,
List<EfRepo.OrganizationRepository> efOrgRepos, List<EfRepo.UserRepository> efUserRepos,
SqlRepo.OrganizationUserRepository sqlOrgUserRepo, SqlRepo.UserRepository sqlUserRepo,
SqlRepo.OrganizationRepository sqlOrgRepo)
@ -67,7 +67,7 @@ public class OrganizationUserRepositoryTests
User user,
Organization org,
OrganizationUserCompare equalityComparer,
List<EfRepo.OrganizationUserRepository> suts,
List<EfAdminConsoleRepo.OrganizationUserRepository> suts,
List<EfRepo.UserRepository> efUserRepos,
List<EfRepo.OrganizationRepository> efOrgRepos,
SqlRepo.OrganizationUserRepository sqlOrgUserRepo,
@ -113,7 +113,7 @@ public class OrganizationUserRepositoryTests
}
[CiSkippedTheory, EfOrganizationUserAutoData]
public async Task DeleteAsync_Works_DataMatches(OrganizationUser orgUser, User user, Organization org, List<EfRepo.OrganizationUserRepository> suts,
public async Task DeleteAsync_Works_DataMatches(OrganizationUser orgUser, User user, Organization org, List<EfAdminConsoleRepo.OrganizationUserRepository> suts,
List<EfRepo.UserRepository> efUserRepos, List<EfRepo.OrganizationRepository> efOrgRepos,
SqlRepo.OrganizationUserRepository sqlOrgUserRepo, SqlRepo.UserRepository sqlUserRepo,
SqlRepo.OrganizationRepository sqlOrgRepo)
@ -188,7 +188,7 @@ public class OrganizationUserRepositoryTests
List<EfAdminConsoleRepo.PolicyRepository> efPolicyRepository,
List<EfRepo.UserRepository> efUserRepository,
List<EfRepo.OrganizationRepository> efOrganizationRepository,
List<EfRepo.OrganizationUserRepository> suts,
List<EfAdminConsoleRepo.OrganizationUserRepository> suts,
List<EfAdminConsoleRepo.ProviderRepository> efProviderRepository,
List<EfAdminConsoleRepo.ProviderOrganizationRepository> efProviderOrganizationRepository,
List<EfAdminConsoleRepo.ProviderUserRepository> efProviderUserRepository,

View File

@ -7,6 +7,7 @@ using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Core.Test.AutoFixture.UserFixtures;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;

View File

@ -5,6 +5,7 @@ using Bit.Core.Test.AutoFixture.UserFixtures;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Infrastructure.EFIntegration.Test.AutoFixture.Relays;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Vault.Repositories;
using Bit.Test.Common.AutoFixture;

View File

@ -9,6 +9,7 @@ using Bit.Infrastructure.EntityFramework.Repositories.Queries;
using Bit.Test.Common.AutoFixture.Attributes;
using LinqToDB;
using Xunit;
using EfAdminConsoleRepo = Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using EfRepo = Bit.Infrastructure.EntityFramework.Repositories;
using EfVaultRepo = Bit.Infrastructure.EntityFramework.Vault.Repositories;
using SqlRepo = Bit.Infrastructure.Dapper.Repositories;
@ -112,7 +113,7 @@ public class CipherRepositoryTests
[CiSkippedTheory, EfOrganizationCipherCustomize, BitAutoData]
public async Task CreateAsync_BumpsOrgUserAccountRevisionDates(Cipher cipher, List<User> users,
List<OrganizationUser> orgUsers, Collection collection, Organization org, List<EfVaultRepo.CipherRepository> suts, List<EfRepo.UserRepository> efUserRepos, List<EfRepo.OrganizationRepository> efOrgRepos,
List<EfRepo.OrganizationUserRepository> efOrgUserRepos, List<EfRepo.CollectionRepository> efCollectionRepos)
List<EfAdminConsoleRepo.OrganizationUserRepository> efOrgUserRepos, List<EfRepo.CollectionRepository> efCollectionRepos)
{
var savedCiphers = new List<Cipher>();
foreach (var sut in suts)

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@ -26,15 +27,23 @@ public static class OrganizationTestHelpers
});
}
/// <summary>
/// Creates an Enterprise organization.
/// </summary>
public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository,
string identifier = "test")
=> organizationRepository.CreateAsync(new Organization
{
Name = $"{identifier}-{Guid.NewGuid()}",
BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULl
Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl
PlanType = PlanType.EnterpriseAnnually
});
/// <summary>
/// Creates a confirmed Owner for the specified organization and user.
/// Does not include any cryptographic material.
/// </summary>
public static Task<OrganizationUser> CreateTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
@ -47,6 +56,17 @@ public static class OrganizationTestHelpers
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateTestOrganizationUserInviteAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = null, // Invites are not linked to a UserId
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.Owner
});
public static Task<Group> CreateTestGroupAsync(
this IGroupRepository groupRepository,
Organization organization,
@ -54,4 +74,14 @@ public static class OrganizationTestHelpers
=> groupRepository.CreateAsync(
new Group { OrganizationId = organization.Id, Name = $"{identifier} {Guid.NewGuid()}" }
);
public static Task<Collection> CreateTestCollectionAsync(
this ICollectionRepository collectionRepository,
Organization organization,
string identifier = "test")
=> collectionRepository.CreateAsync(new Collection
{
OrganizationId = organization.Id,
Name = $"{identifier} {Guid.NewGuid()}"
});
}

View File

@ -0,0 +1,105 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class CollectionRepositoryCreateTests
{
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_WithAccess_Works(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user1 = await userRepository.CreateTestUserAsync();
var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1);
var user2 = await userRepository.CreateTestUserAsync();
var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2);
var group1 = await groupRepository.CreateTestGroupAsync(organization);
var group2 = await groupRepository.CreateTestGroupAsync(organization);
var collection = new Collection
{
Name = "Test Collection Name",
OrganizationId = organization.Id,
};
// Act
await collectionRepository.CreateAsync(collection,
[
new CollectionAccessSelection { Id = group1.Id, Manage = true, HidePasswords = true, ReadOnly = false, },
new CollectionAccessSelection { Id = group2.Id, Manage = false, HidePasswords = false, ReadOnly = true, },
],
[
new CollectionAccessSelection { Id = orgUser1.Id, Manage = true, HidePasswords = false, ReadOnly = true },
new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = true, ReadOnly = false },
]
);
// Assert
var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id);
Assert.NotNull(actualCollection);
Assert.Equal("Test Collection Name", actualCollection.Name);
var groups = actualAccess.Groups.ToArray();
Assert.Equal(2, groups.Length);
Assert.Single(groups, g => g.Id == group1.Id && g.Manage && g.HidePasswords && !g.ReadOnly);
Assert.Single(groups, g => g.Id == group2.Id && !g.Manage && !g.HidePasswords && g.ReadOnly);
var users = actualAccess.Users.ToArray();
Assert.Equal(2, users.Length);
Assert.Single(users, u => u.Id == orgUser1.Id && u.Manage && !u.HidePasswords && u.ReadOnly);
Assert.Single(users, u => u.Id == orgUser2.Id && !u.Manage && u.HidePasswords && !u.ReadOnly);
// Clean up data
await userRepository.DeleteAsync(user1);
await userRepository.DeleteAsync(user2);
await organizationRepository.DeleteAsync(organization);
await groupRepository.DeleteManyAsync([group1.Id, group2.Id]);
await organizationUserRepository.DeleteManyAsync([orgUser1.Id, orgUser2.Id]);
}
/// <remarks>
/// Makes sure that the sproc handles empty sets.
/// </remarks>
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_WithNoAccess_Works(
IOrganizationRepository organizationRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var collection = new Collection
{
Name = "Test Collection Name",
OrganizationId = organization.Id,
};
// Act
await collectionRepository.CreateAsync(collection, [], []);
// Assert
var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id);
Assert.NotNull(actualCollection);
Assert.Equal("Test Collection Name", actualCollection.Name);
Assert.Empty(actualAccess.Groups);
Assert.Empty(actualAccess.Users);
// Clean up
await organizationRepository.DeleteAsync(organization);
}
}

View File

@ -0,0 +1,147 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class CollectionRepositoryReplaceTests
{
[DatabaseTheory, DatabaseData]
public async Task ReplaceAsync_WithAccess_Works(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user1 = await userRepository.CreateTestUserAsync();
var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1);
var user2 = await userRepository.CreateTestUserAsync();
var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2);
var user3 = await userRepository.CreateTestUserAsync();
var orgUser3 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user3);
var group1 = await groupRepository.CreateTestGroupAsync(organization);
var group2 = await groupRepository.CreateTestGroupAsync(organization);
var group3 = await groupRepository.CreateTestGroupAsync(organization);
var collection = new Collection
{
Name = "Test Collection Name",
OrganizationId = organization.Id,
};
await collectionRepository.CreateAsync(collection,
[
new CollectionAccessSelection { Id = group1.Id, Manage = true, HidePasswords = true, ReadOnly = false, },
new CollectionAccessSelection { Id = group2.Id, Manage = false, HidePasswords = false, ReadOnly = true, },
],
[
new CollectionAccessSelection { Id = orgUser1.Id, Manage = true, HidePasswords = false, ReadOnly = true },
new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = true, ReadOnly = false },
]
);
// Act
collection.Name = "Updated Collection Name";
await collectionRepository.ReplaceAsync(collection,
[
// Delete group1
// Update group2:
new CollectionAccessSelection { Id = group2.Id, Manage = true, HidePasswords = true, ReadOnly = false, },
// Add group3:
new CollectionAccessSelection { Id = group3.Id, Manage = false, HidePasswords = false, ReadOnly = true, },
],
[
// Delete orgUser1
// Update orgUser2:
new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = false, ReadOnly = true },
// Add orgUser3:
new CollectionAccessSelection { Id = orgUser3.Id, Manage = true, HidePasswords = false, ReadOnly = true },
]
);
// Assert
var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id);
Assert.NotNull(actualCollection);
Assert.Equal("Updated Collection Name", actualCollection.Name);
var groups = actualAccess.Groups.ToArray();
Assert.Equal(2, groups.Length);
Assert.Single(groups, g => g.Id == group2.Id && g.Manage && g.HidePasswords && !g.ReadOnly);
Assert.Single(groups, g => g.Id == group3.Id && !g.Manage && !g.HidePasswords && g.ReadOnly);
var users = actualAccess.Users.ToArray();
Assert.Equal(2, users.Length);
Assert.Single(users, u => u.Id == orgUser2.Id && !u.Manage && !u.HidePasswords && u.ReadOnly);
Assert.Single(users, u => u.Id == orgUser3.Id && u.Manage && !u.HidePasswords && u.ReadOnly);
// Clean up data
await userRepository.DeleteAsync(user1);
await userRepository.DeleteAsync(user2);
await userRepository.DeleteAsync(user3);
await organizationRepository.DeleteAsync(organization);
}
/// <remarks>
/// Makes sure that the sproc handles empty sets.
/// </remarks>
[DatabaseTheory, DatabaseData]
public async Task ReplaceAsync_WithNoAccess_Works(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
var group = await groupRepository.CreateTestGroupAsync(organization);
var collection = new Collection
{
Name = "Test Collection Name",
OrganizationId = organization.Id,
};
await collectionRepository.CreateAsync(collection,
[
new CollectionAccessSelection { Id = group.Id, Manage = true, HidePasswords = false, ReadOnly = true },
],
[
new CollectionAccessSelection { Id = orgUser.Id, Manage = true, HidePasswords = false, ReadOnly = true },
]);
// Act
collection.Name = "Updated Collection Name";
await collectionRepository.ReplaceAsync(collection, [], []);
// Assert
var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id);
Assert.NotNull(actualCollection);
Assert.Equal("Updated Collection Name", actualCollection.Name);
Assert.Empty(actualAccess.Groups);
Assert.Empty(actualAccess.Users);
// Clean up
await userRepository.DeleteAsync(user);
await organizationRepository.DeleteAsync(organization);
}
}

View File

@ -7,7 +7,7 @@ using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.Repositories;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class CollectionRepositoryTests
{
@ -463,147 +463,4 @@ public class CollectionRepositoryTests
Assert.False(c3.Unmanaged);
});
}
[DatabaseTheory, DatabaseData]
public async Task ReplaceAsync_Works(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
ICollectionRepository collectionRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
PlanType = PlanType.EnterpriseAnnually,
Plan = "Test Plan",
BillingEmail = "billing@email.com"
});
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
});
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
});
var orgUser3 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
});
var group1 = await groupRepository.CreateAsync(new Group
{
Name = "Test Group #1",
OrganizationId = organization.Id,
});
var group2 = await groupRepository.CreateAsync(new Group
{
Name = "Test Group #2",
OrganizationId = organization.Id,
});
var group3 = await groupRepository.CreateAsync(new Group
{
Name = "Test Group #3",
OrganizationId = organization.Id,
});
var collection = new Collection
{
Name = "Test Collection Name",
OrganizationId = organization.Id,
};
await collectionRepository.CreateAsync(collection,
[
new CollectionAccessSelection { Id = group1.Id, Manage = true, HidePasswords = true, ReadOnly = false, },
new CollectionAccessSelection { Id = group2.Id, Manage = false, HidePasswords = false, ReadOnly = true, },
],
[
new CollectionAccessSelection { Id = orgUser1.Id, Manage = true, HidePasswords = false, ReadOnly = true },
new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = true, ReadOnly = false },
]
);
collection.Name = "Updated Collection Name";
await collectionRepository.ReplaceAsync(collection,
[
// Should delete group1
new CollectionAccessSelection { Id = group2.Id, Manage = true, HidePasswords = true, ReadOnly = false, },
// Should add group3
new CollectionAccessSelection { Id = group3.Id, Manage = false, HidePasswords = false, ReadOnly = true, },
],
[
// Should delete orgUser1
new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = false, ReadOnly = true },
// Should add orgUser3
new CollectionAccessSelection { Id = orgUser3.Id, Manage = true, HidePasswords = false, ReadOnly = true },
]
);
// Assert it
var info = await collectionRepository.GetByIdWithPermissionsAsync(collection.Id, user.Id, true);
Assert.NotNull(info);
Assert.Equal("Updated Collection Name", info.Name);
var groups = info.Groups.ToArray();
Assert.Equal(2, groups.Length);
var actualGroup2 = Assert.Single(groups.Where(g => g.Id == group2.Id));
Assert.True(actualGroup2.Manage);
Assert.True(actualGroup2.HidePasswords);
Assert.False(actualGroup2.ReadOnly);
var actualGroup3 = Assert.Single(groups.Where(g => g.Id == group3.Id));
Assert.False(actualGroup3.Manage);
Assert.False(actualGroup3.HidePasswords);
Assert.True(actualGroup3.ReadOnly);
var users = info.Users.ToArray();
Assert.Equal(2, users.Length);
var actualOrgUser2 = Assert.Single(users.Where(u => u.Id == orgUser2.Id));
Assert.False(actualOrgUser2.Manage);
Assert.False(actualOrgUser2.HidePasswords);
Assert.True(actualOrgUser2.ReadOnly);
var actualOrgUser3 = Assert.Single(users.Where(u => u.Id == orgUser3.Id));
Assert.True(actualOrgUser3.Manage);
Assert.False(actualOrgUser3.HidePasswords);
Assert.True(actualOrgUser3.ReadOnly);
// Clean up data
await userRepository.DeleteAsync(user);
await organizationRepository.DeleteAsync(organization);
await groupRepository.DeleteManyAsync([group1.Id, group2.Id, group3.Id]);
await organizationUserRepository.DeleteManyAsync([orgUser1.Id, orgUser2.Id, orgUser3.Id]);
}
}

View File

@ -0,0 +1,88 @@
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
public class OrganizationUserReplaceTests
{
/// <summary>
/// Specifically tests OrganizationUsers in the invited state, which is unique because
/// they're not linked to a UserId.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsInvited_Success(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization);
// Act: update the user, including collection access so we test this overloaded method
orgUser.Type = OrganizationUserType.Admin;
orgUser.AccessSecretsManager = true;
var collection = await collectionRepository.CreateTestCollectionAsync(organization);
await organizationUserRepository.ReplaceAsync(orgUser, [
new CollectionAccessSelection { Id = collection.Id, Manage = true }
]);
// Assert
var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(orgUser.Id);
Assert.NotNull(actualOrgUser);
Assert.Equal(OrganizationUserType.Admin, actualOrgUser.Type);
Assert.True(actualOrgUser.AccessSecretsManager);
var collectionAccess = Assert.Single(actualCollections);
Assert.Equal(collection.Id, collectionAccess.Id);
Assert.True(collectionAccess.Manage);
}
/// <summary>
/// Tests OrganizationUsers in the Confirmed status, which is a stand-in for all other
/// non-Invited statuses (which are all linked to a UserId).
/// </summary>
/// <param name="organizationRepository"></param>
/// <param name="organizationUserRepository"></param>
/// <param name="collectionRepository"></param>
[DatabaseTheory, DatabaseData]
public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
// OrganizationUser is linked with the User in the Confirmed status
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
// Act: update the user, including collection access so we test this overloaded method
orgUser.Type = OrganizationUserType.Admin;
orgUser.AccessSecretsManager = true;
var collection = await collectionRepository.CreateTestCollectionAsync(organization);
await organizationUserRepository.ReplaceAsync(orgUser, [
new CollectionAccessSelection { Id = collection.Id, Manage = true }
]);
// Assert
var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(orgUser.Id);
Assert.NotNull(actualOrgUser);
Assert.Equal(OrganizationUserType.Admin, actualOrgUser.Type);
Assert.True(actualOrgUser.AccessSecretsManager);
var collectionAccess = Assert.Single(actualCollections);
Assert.Equal(collection.Id, collectionAccess.Id);
Assert.True(collectionAccess.Manage);
// Account revision date should be updated to a later date
var actualUser = await userRepository.GetByIdAsync(user.Id);
Assert.NotNull(actualUser);
Assert.True(actualUser.AccountRevisionDate.CompareTo(user.AccountRevisionDate) > 0);
}
}

View File

@ -8,7 +8,7 @@ using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
public class OrganizationUserRepositoryTests
{

View File

@ -0,0 +1,139 @@
-- Add `Emails` field that stores a comma-separated list of email addresses for
-- email/OTP authentication to table and write methods. The read methods
-- don't need to be updated because they all use `*`.
IF NOT EXISTS(
SELECT *
FROM [sys].[columns]
WHERE [object_id] = OBJECT_ID(N'[dbo].[Send]')
AND [name] = 'Emails')
BEGIN
ALTER TABLE [dbo].[Send] ADD [Emails] NVARCHAR(1024) NULL;
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Send_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Send]
SET
[UserId] = @UserId,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Key] = @Key,
[Password] = @Password,
[MaxAccessCount] = @MaxAccessCount,
[AccessCount] = @AccessCount,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[ExpirationDate] = @ExpirationDate,
[DeletionDate] = @DeletionDate,
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails
WHERE
[Id] = @Id
IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Send_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(1024) = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Send]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Key],
[Password],
[MaxAccessCount],
[AccessCount],
[CreationDate],
[RevisionDate],
[ExpirationDate],
[DeletionDate],
[Disabled],
[HideEmail],
[CipherId],
[Emails]
)
VALUES
(
@Id,
@UserId,
@OrganizationId,
@Type,
@Data,
@Key,
@Password,
@MaxAccessCount,
@AccessCount,
@CreationDate,
@RevisionDate,
@ExpirationDate,
@DeletionDate,
@Disabled,
@HideEmail,
@CipherId,
@Emails
)
IF @UserId IS NOT NULL
BEGIN
IF @Type = 1 --File
BEGIN
EXEC [dbo].[User_UpdateStorage] @UserId
END
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO

View File

@ -0,0 +1,67 @@
IF EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_NAME = 'SsoUser'
AND COLUMN_NAME = 'ExternalId'
AND DATA_TYPE = 'nvarchar'
AND CHARACTER_MAXIMUM_LENGTH < 300
)
BEGIN
-- Update table ExternalId column size
ALTER TABLE [dbo].[SsoUser]
ALTER COLUMN [ExternalId] NVARCHAR(300) NOT NULL
END
GO
-- Update stored procedures to handle the new ExternalId column size
CREATE OR ALTER PROCEDURE [dbo].[SsoUser_Create]
@Id BIGINT OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[SsoUser]
(
[UserId],
[OrganizationId],
[ExternalId],
[CreationDate]
)
VALUES
(
@UserId,
@OrganizationId,
@ExternalId,
@CreationDate
)
SET @Id = SCOPE_IDENTITY();
END
GO
CREATE OR ALTER PROCEDURE [dbo].[SsoUser_Update]
@Id BIGINT OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[SsoUser]
SET
[UserId] = @UserId,
[OrganizationId] = @OrganizationId,
[ExternalId] = @ExternalId,
[CreationDate] = @CreationDate
WHERE
[Id] = @Id
END
GO

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class SsoExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalId",
table: "SsoUser",
type: "varchar(300)",
maxLength: 300,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(50)",
oldMaxLength: 50,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalId",
table: "SsoUser",
type: "varchar(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "varchar(300)",
oldMaxLength: 300,
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class _20250520_00_AddSendEmails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Emails",
table: "Send",
type: "varchar(1024)",
maxLength: 1024,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Emails",
table: "Send");
}
}

View File

@ -679,8 +679,8 @@ namespace Bit.MySqlMigrations.Migrations
.HasColumnType("datetime(6)");
b.Property<string>("ExternalId")
.HasMaxLength(50)
.HasColumnType("varchar(50)");
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("char(36)");
@ -1437,6 +1437,10 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<bool>("Disabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Emails")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime(6)");

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class SsoExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalId",
table: "SsoUser",
type: "character varying(300)",
maxLength: 300,
nullable: true,
collation: "postgresIndetermanisticCollation",
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50,
oldNullable: true,
oldCollation: "postgresIndetermanisticCollation");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ExternalId",
table: "SsoUser",
type: "character varying(50)",
maxLength: 50,
nullable: true,
collation: "postgresIndetermanisticCollation",
oldClrType: typeof(string),
oldType: "character varying(300)",
oldMaxLength: 300,
oldNullable: true,
oldCollation: "postgresIndetermanisticCollation");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class _20250520_00_AddSendEmails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Emails",
table: "Send",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Emails",
table: "Send");
}
}

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