mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-07 19:07:45 +01:00
Merge branch 'main' into autofill/pm-5189-fix-issues-present-with-inline-menu-rendering-in-iframes
This commit is contained in:
commit
5e875b29b8
@ -206,11 +206,29 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["libs/tools/generator/extensions/src/**/*.ts"],
|
||||
"files": ["libs/tools/generator/extensions/history/src/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{ "patterns": ["@bitwarden/generator-extensions/*", "src/**/*"] }
|
||||
{ "patterns": ["@bitwarden/generator-history/*", "src/**/*"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["libs/tools/generator/extensions/legacy/src/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{ "patterns": ["@bitwarden/generator-legacy/*", "src/**/*"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["libs/tools/generator/extensions/navigation/src/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{ "patterns": ["@bitwarden/generator-navigation/*", "src/**/*"] }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2024.6.2",
|
||||
"version": "2024.6.3",
|
||||
"scripts": {
|
||||
"build": "cross-env MANIFEST_VERSION=3 webpack",
|
||||
"build:mv2": "webpack",
|
||||
|
@ -12,7 +12,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
|
||||
|
||||
/**
|
||||
* This guard verifies the user's authetication status.
|
||||
* This guard verifies the user's authentication status.
|
||||
* If "Locked", it saves the intended route in memory and redirects to the lock screen. Otherwise, the intended route is allowed.
|
||||
*/
|
||||
export const fido2AuthGuard: CanActivateFn = async (
|
||||
@ -27,8 +27,10 @@ export const fido2AuthGuard: CanActivateFn = async (
|
||||
|
||||
if (authStatus === AuthenticationStatus.Locked) {
|
||||
// Appending fromLock=true to the query params to indicate that the user is being redirected from the lock screen, this is used for user verification.
|
||||
const previousUrl = `${state.url}&fromLock=true`;
|
||||
routerService.setPreviousUrl(previousUrl);
|
||||
// TODO: Revert to use previousUrl once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
// const previousUrl = `${state.url}&fromLock=true`;
|
||||
routerService.setPreviousUrl(state.url);
|
||||
return router.createUrlTree(["/lock"], { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.6.2",
|
||||
"version": "2024.6.3",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "__MSG_appName__",
|
||||
"version": "2024.6.2",
|
||||
"version": "2024.6.3",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
@ -27,6 +27,7 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service";
|
||||
import {
|
||||
@ -59,7 +60,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
protected data$: Observable<ViewData>;
|
||||
protected sessionId?: string;
|
||||
protected senderTabId?: string;
|
||||
protected fromLock?: boolean;
|
||||
protected ciphers?: CipherView[] = [];
|
||||
protected displayedCiphers?: CipherView[] = [];
|
||||
protected loading = false;
|
||||
@ -78,6 +78,7 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
private browserMessagingApi: ZonedMessageListenerService,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private fido2UserVerificationService: Fido2UserVerificationService,
|
||||
) {}
|
||||
|
||||
@ -90,7 +91,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
sessionId: queryParamMap.get("sessionId"),
|
||||
senderTabId: queryParamMap.get("senderTabId"),
|
||||
senderUrl: queryParamMap.get("senderUrl"),
|
||||
fromLock: queryParamMap.get("fromLock"),
|
||||
})),
|
||||
);
|
||||
|
||||
@ -103,7 +103,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
this.sessionId = queryParams.sessionId;
|
||||
this.senderTabId = queryParams.senderTabId;
|
||||
this.url = queryParams.senderUrl;
|
||||
this.fromLock = queryParams.fromLock === "true";
|
||||
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
|
||||
if (
|
||||
message.type === "NewSessionCreatedRequest" &&
|
||||
@ -213,11 +212,9 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
protected async submit() {
|
||||
const data = this.message$.value;
|
||||
if (data?.type === "PickCredentialRequest") {
|
||||
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
|
||||
data.userVerification,
|
||||
this.cipher,
|
||||
this.fromLock,
|
||||
);
|
||||
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
@ -238,11 +235,9 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
|
||||
data.userVerification,
|
||||
this.cipher,
|
||||
this.fromLock,
|
||||
);
|
||||
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
@ -259,21 +254,16 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
const data = this.message$.value;
|
||||
if (data?.type === "ConfirmNewCredentialRequest") {
|
||||
const name = data.credentialName || data.rpId;
|
||||
const userVerified = await this.fido2UserVerificationService.handleUserVerification(
|
||||
data.userVerification,
|
||||
this.cipher,
|
||||
this.fromLock,
|
||||
);
|
||||
|
||||
if (!data.userVerification || userVerified) {
|
||||
await this.createNewCipher(name);
|
||||
}
|
||||
// TODO: Revert to check for user verification once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
await this.createNewCipher(name);
|
||||
|
||||
// We are bypassing user verification pending approval.
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
cipherId: this.cipher?.id,
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
userVerified,
|
||||
userVerified: data.userVerification,
|
||||
});
|
||||
}
|
||||
|
||||
@ -322,7 +312,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
uilocation: "popout",
|
||||
senderTabId: this.senderTabId,
|
||||
sessionId: this.sessionId,
|
||||
fromLock: this.fromLock,
|
||||
userVerification: data.userVerification,
|
||||
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
|
||||
},
|
||||
@ -393,6 +382,20 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
private async handleUserVerification(
|
||||
userVerificationRequested: boolean,
|
||||
cipher: CipherView,
|
||||
): Promise<boolean> {
|
||||
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
|
||||
|
||||
if (masterPasswordRepromptRequired) {
|
||||
return await this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
return userVerificationRequested;
|
||||
}
|
||||
|
||||
private send(msg: BrowserFido2Message) {
|
||||
BrowserFido2UserInterfaceSession.sendMessage({
|
||||
sessionId: this.sessionId,
|
||||
|
@ -144,6 +144,7 @@
|
||||
appStopClick
|
||||
(click)="removePasskey()"
|
||||
appA11yTitle="{{ 'removePasskey' | i18n }}"
|
||||
*ngIf="!(!cipher.edit && editMode)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
@ -542,6 +543,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="!(!cipher.edit && editMode)"
|
||||
appStopClick
|
||||
(click)="removeUri(u)"
|
||||
appA11yTitle="{{ 'remove' | i18n }}"
|
||||
@ -558,6 +560,7 @@
|
||||
[hidden]="$any(u).showUriOptionsInput === true"
|
||||
placeholder="{{ 'ex' | i18n }} https://google.com"
|
||||
inputmode="url"
|
||||
[readonly]="!cipher.edit && editMode"
|
||||
appInputVerbatim
|
||||
/>
|
||||
<label for="loginUriMatch{{ i }}" class="sr-only">
|
||||
@ -607,6 +610,7 @@
|
||||
appA11yTitle="{{ 'toggleOptions' | i18n }}"
|
||||
(click)="toggleUriOptions(u)"
|
||||
[attr.aria-pressed]="$any(u).showOptions === true"
|
||||
[disabled]="!cipher.edit && editMode"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-lg bwi-cog" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
@ -170,17 +170,14 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
|
||||
async submit(): Promise<boolean> {
|
||||
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
const { isFido2Session, sessionId, userVerification, fromLock } = fido2SessionData;
|
||||
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
|
||||
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
|
||||
|
||||
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
if (
|
||||
inFido2PopoutWindow &&
|
||||
userVerification &&
|
||||
!(await this.fido2UserVerificationService.handleUserVerification(
|
||||
userVerification,
|
||||
this.cipher,
|
||||
fromLock,
|
||||
))
|
||||
!(await this.handleFido2UserVerification(sessionId, userVerification))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@ -389,4 +386,13 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
this.load().catch((error) => this.logService.error(error));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove and use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
private async handleFido2UserVerification(
|
||||
sessionId: string,
|
||||
userVerification: boolean,
|
||||
): Promise<boolean> {
|
||||
// We are bypassing user verification pending approval for production.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,9 @@
|
||||
"@bitwarden/components": ["../../libs/components/src"],
|
||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": [
|
||||
"../../libs/tools/export/vault-export/vault-export-core/src"
|
||||
],
|
||||
|
@ -127,7 +127,7 @@
|
||||
appStopClick
|
||||
(click)="removePasskey()"
|
||||
appA11yTitle="{{ 'removePasskey' | i18n }}"
|
||||
[disabled]="!cipher.edit && editMode"
|
||||
*ngIf="!(!cipher.edit && editMode)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
@ -488,7 +488,7 @@
|
||||
appStopClick
|
||||
(click)="removeUri(u)"
|
||||
appA11yTitle="{{ 'remove' | i18n }}"
|
||||
[disabled]="!cipher.edit && editMode"
|
||||
*ngIf="!(!cipher.edit && editMode)"
|
||||
>
|
||||
<i class="bwi bwi-minus-circle bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
@ -500,6 +500,7 @@
|
||||
name="Login.Uris[{{ i }}].Uri"
|
||||
[(ngModel)]="u.uri"
|
||||
placeholder="{{ 'ex' | i18n }} https://google.com"
|
||||
[readonly]="!cipher.edit && editMode"
|
||||
appInputVerbatim
|
||||
/>
|
||||
<label for="loginUriMatch{{ i }}" class="sr-only">
|
||||
@ -533,6 +534,7 @@
|
||||
($any(u).showOptions == null && u.match == null)
|
||||
)
|
||||
"
|
||||
[disabled]="!cipher.edit && editMode"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-cog" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
@ -19,7 +19,9 @@
|
||||
"@bitwarden/components": ["../../libs/components/src"],
|
||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": [
|
||||
"../../libs/tools/export/vault-export/vault-export-core/src"
|
||||
],
|
||||
|
@ -3,9 +3,9 @@
|
||||
<p class="tw-text-xl tw-text-center tw-mb-4">{{ "deleteOrganization" | i18n }}</p>
|
||||
<div class="tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
|
||||
<div class="tw-p-5">
|
||||
<app-callout type="warning">{{
|
||||
"deletingOrganizationIsPermanentWarning" | i18n: name
|
||||
}}</app-callout>
|
||||
<bit-callout type="warning">
|
||||
{{ "deletingOrganizationIsPermanentWarning" | i18n: name }}
|
||||
</bit-callout>
|
||||
<p class="tw-text-center">
|
||||
<strong>{{ name }}</strong>
|
||||
</p>
|
||||
|
@ -1,11 +1,11 @@
|
||||
<bit-dialog dialogSize="large" [title]="'confirmUsers' | i18n" [loading]="loading">
|
||||
<ng-container bitDialogContent>
|
||||
<app-callout type="danger" *ngIf="filteredUsers.length <= 0">
|
||||
<bit-callout type="danger" *ngIf="filteredUsers.length <= 0">
|
||||
{{ "noSelectedUsersApplicable" | i18n }}
|
||||
</app-callout>
|
||||
<app-callout type="error" *ngIf="error">
|
||||
</bit-callout>
|
||||
<bit-callout type="danger" [title]="'error' | i18n" *ngIf="error">
|
||||
{{ error }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="!loading && !done">
|
||||
<p bitTypography="body1">
|
||||
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
|
||||
|
@ -1,18 +1,18 @@
|
||||
<bit-dialog dialogSize="large" [title]="'removeUsers' | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<app-callout type="danger" *ngIf="users.length <= 0">
|
||||
<bit-callout type="danger" *ngIf="users.length <= 0">
|
||||
{{ "noSelectedUsersApplicable" | i18n }}
|
||||
</app-callout>
|
||||
<app-callout type="error" *ngIf="error">
|
||||
</bit-callout>
|
||||
<bit-callout type="danger" [title]="'error' | i18n" *ngIf="error">
|
||||
{{ error }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<ng-container *ngIf="!done">
|
||||
<app-callout type="warning" *ngIf="users.length > 0 && !error">
|
||||
<bit-callout type="warning" *ngIf="users.length > 0 && !error">
|
||||
<p bitTypography="body1">{{ removeUsersWarning }}</p>
|
||||
<p *ngIf="this.showNoMasterPasswordWarning" bitTypography="body1">
|
||||
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
|
||||
</p>
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
|
@ -48,14 +48,14 @@
|
||||
<ng-container *ngIf="firstLoaded">
|
||||
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
|
||||
<ng-container *ngIf="dataSource.filteredData.length">
|
||||
<app-callout
|
||||
<bit-callout
|
||||
type="info"
|
||||
title="{{ 'confirmUsers' | i18n }}"
|
||||
icon="bwi bwi-check-circle"
|
||||
icon="bwi-check-circle"
|
||||
*ngIf="showConfirmUsers"
|
||||
>
|
||||
{{ "usersNeedConfirmed" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
|
||||
from overflowing the <main> element. -->
|
||||
<cdk-virtual-scroll-viewport scrollWindow [itemSize]="rowHeight" class="tw-pb-8">
|
||||
|
@ -13,6 +13,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
||||
@ -157,7 +158,7 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedKeys", () => {
|
||||
describe("getRotatedData", () => {
|
||||
beforeEach(() => {
|
||||
organizationService.getAll.mockResolvedValue([
|
||||
createOrganization("1", "org1"),
|
||||
@ -175,12 +176,24 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
|
||||
it("should return all re-encrypted account recovery keys", async () => {
|
||||
const result = await sut.getRotatedKeys(
|
||||
const result = await sut.getRotatedData(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
"mockUserId" as UserId,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(
|
||||
sut.getRotatedData(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
null,
|
||||
"mockUserId" as UserId,
|
||||
),
|
||||
).rejects.toThrow("New user key is required for rotation.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
@ -19,12 +20,15 @@ import { KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class OrganizationUserResetPasswordService {
|
||||
export class OrganizationUserResetPasswordService
|
||||
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
|
||||
{
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
@ -129,11 +133,16 @@ export class OrganizationUserResetPasswordService {
|
||||
|
||||
/**
|
||||
* Returns existing account recovery keys re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if new user key is null
|
||||
* @returns a list of account recovery keys that have been re-encrypted with the new user key
|
||||
*/
|
||||
async getRotatedKeys(
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required for rotation.");
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "disableSendExemption" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="info" *ngIf="showKeyConnectorInfo">
|
||||
<bit-callout type="info" *ngIf="showKeyConnectorInfo">
|
||||
{{ "keyConnectorPolicyRestriction" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<div [formGroup]="data">
|
||||
<bit-form-control>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "personalOwnershipExemption" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
|
@ -1,9 +1,9 @@
|
||||
<app-callout type="tip" title="{{ 'prerequisite' | i18n }}">
|
||||
<bit-callout type="info" title="{{ 'prerequisite' | i18n }}">
|
||||
{{ "requireSsoPolicyReq" | i18n }}
|
||||
</app-callout>
|
||||
<app-callout type="warning">
|
||||
</bit-callout>
|
||||
<bit-callout type="warning">
|
||||
{{ "requireSsoExemption" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "sendOptionsExemption" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "singleOrgPolicyWarning" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "twoStepLoginPolicyWarning" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
|
||||
|
@ -2,9 +2,9 @@
|
||||
<bit-dialog [loading]="!loaded">
|
||||
<span bitDialogTitle>{{ "deleteOrganization" | i18n }}</span>
|
||||
<div bitDialogContent>
|
||||
<app-callout type="warning">{{
|
||||
"deletingOrganizationIsPermanentWarning" | i18n: organization?.name
|
||||
}}</app-callout>
|
||||
<bit-callout type="warning">
|
||||
{{ "deletingOrganizationIsPermanentWarning" | i18n: organization?.name }}
|
||||
</bit-callout>
|
||||
<p id="organizationDeleteDescription">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
|
@ -190,7 +190,7 @@ describe("WebauthnAdminService", () => {
|
||||
it("should throw when old userkey is null", async () => {
|
||||
const newUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||
try {
|
||||
await service.rotateWebAuthnKeys(null, newUserKey);
|
||||
await service.getRotatedData(null, newUserKey, null);
|
||||
} catch (error) {
|
||||
expect(error).toEqual(new Error("oldUserKey is required"));
|
||||
}
|
||||
@ -198,7 +198,7 @@ describe("WebauthnAdminService", () => {
|
||||
it("should throw when new userkey is null", async () => {
|
||||
const oldUserKey = makeSymmetricCryptoKey(64) as UserKey;
|
||||
try {
|
||||
await service.rotateWebAuthnKeys(oldUserKey, null);
|
||||
await service.getRotatedData(oldUserKey, null, null);
|
||||
} catch (error) {
|
||||
expect(error).toEqual(new Error("newUserKey is required"));
|
||||
}
|
||||
@ -222,7 +222,7 @@ describe("WebauthnAdminService", () => {
|
||||
.mockResolvedValue(
|
||||
new RotateableKeySet<PrfKey>(mockEncryptedUserKey, mockEncryptedPublicKey),
|
||||
);
|
||||
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
|
||||
await service.getRotatedData(oldUserKey, newUserKey, null);
|
||||
expect(rotateKeySetMock).toHaveBeenCalledWith(
|
||||
expect.any(RotateableKeySet),
|
||||
oldUserKey,
|
||||
@ -242,7 +242,7 @@ describe("WebauthnAdminService", () => {
|
||||
],
|
||||
} as any);
|
||||
const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet");
|
||||
await service.rotateWebAuthnKeys(oldUserKey, newUserKey);
|
||||
await service.getRotatedData(oldUserKey, newUserKey, null);
|
||||
expect(rotateKeySetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Injectable, Optional } from "@angular/core";
|
||||
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
|
||||
|
||||
import { PrfKeySet } from "@bitwarden/auth/common";
|
||||
import { PrfKeySet, UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
|
||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||
@ -9,6 +9,7 @@ import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/a
|
||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
|
||||
@ -25,7 +26,9 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service
|
||||
/**
|
||||
* Service for managing WebAuthnLogin credentials.
|
||||
*/
|
||||
export class WebauthnLoginAdminService {
|
||||
export class WebauthnLoginAdminService
|
||||
implements UserKeyRotationDataProvider<WebauthnRotateCredentialRequest>
|
||||
{
|
||||
static readonly MaxCredentialCount = 5;
|
||||
|
||||
private navigatorCredentials: CredentialsContainer;
|
||||
@ -283,11 +286,13 @@ export class WebauthnLoginAdminService {
|
||||
*
|
||||
* @param oldUserKey The old user key
|
||||
* @param newUserKey The new user key
|
||||
* @param userId The user id
|
||||
* @returns A promise that returns an array of rotate credential requests when resolved.
|
||||
*/
|
||||
async rotateWebAuthnKeys(
|
||||
async getRotatedData(
|
||||
oldUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<WebauthnRotateCredentialRequest[]> {
|
||||
if (!oldUserKey) {
|
||||
throw new Error("oldUserKey is required");
|
||||
|
@ -11,6 +11,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
@ -223,8 +224,11 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedKeys", () => {
|
||||
let mockUserKey: UserKey;
|
||||
describe("getRotatedData", () => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
|
||||
const allowedStatuses = [
|
||||
EmergencyAccessStatusType.Confirmed,
|
||||
EmergencyAccessStatusType.RecoveryInitiated,
|
||||
@ -242,9 +246,6 @@ describe("EmergencyAccessService", () => {
|
||||
} as ListResponse<EmergencyAccessGranteeDetailsResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
|
||||
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
|
||||
apiService.getUserPublicKey.mockResolvedValue({
|
||||
userId: "mockUserId",
|
||||
@ -259,10 +260,20 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
|
||||
it("Only returns emergency accesses with allowed statuses", async () => {
|
||||
const result = await emergencyAccessService.getRotatedKeys(mockUserKey);
|
||||
const result = await emergencyAccessService.getRotatedData(
|
||||
mockOriginalUserKey,
|
||||
mockNewUserKey,
|
||||
"mockUserId" as UserId,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(allowedStatuses.length);
|
||||
});
|
||||
|
||||
it("throws if new user key is null", async () => {
|
||||
await expect(
|
||||
emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
|
||||
).rejects.toThrow("New user key is required for rotation.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
@ -15,6 +16,7 @@ import { KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@ -35,7 +37,9 @@ import {
|
||||
import { EmergencyAccessApiService } from "./emergency-access-api.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmergencyAccessService {
|
||||
export class EmergencyAccessService
|
||||
implements UserKeyRotationDataProvider<EmergencyAccessWithIdRequest>
|
||||
{
|
||||
constructor(
|
||||
private emergencyAccessApiService: EmergencyAccessApiService,
|
||||
private apiService: ApiService,
|
||||
@ -286,9 +290,21 @@ export class EmergencyAccessService {
|
||||
/**
|
||||
* Returns existing emergency access keys re-encrypted with new user key.
|
||||
* Intended for grantor.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if newUserKey is nullish
|
||||
* @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
|
||||
*/
|
||||
async getRotatedKeys(newUserKey: UserKey): Promise<EmergencyAccessWithIdRequest[]> {
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<EmergencyAccessWithIdRequest[]> {
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required for rotation.");
|
||||
}
|
||||
|
||||
const requests: EmergencyAccessWithIdRequest[] = [];
|
||||
const existingEmergencyAccess =
|
||||
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
|
||||
|
@ -1,37 +1,30 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
|
||||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../../../libs/common/spec/fake-account-service";
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { StateService } from "../../core";
|
||||
import { WebauthnLoginAdminService } from "../core";
|
||||
import { EmergencyAccessService } from "../emergency-access";
|
||||
import { EmergencyAccessWithIdRequest } from "../emergency-access/request/emergency-access-update.request";
|
||||
|
||||
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
||||
import { UserKeyRotationService } from "./user-key-rotation.service";
|
||||
@ -48,16 +41,19 @@ describe("KeyRotationService", () => {
|
||||
let mockDeviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
|
||||
let mockCryptoService: MockProxy<CryptoService>;
|
||||
let mockEncryptService: MockProxy<EncryptService>;
|
||||
let mockStateService: MockProxy<StateService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
||||
let mockSyncService: MockProxy<SyncService>;
|
||||
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService();
|
||||
|
||||
const mockUser = {
|
||||
id: "mockUserId" as UserId,
|
||||
email: "mockEmail",
|
||||
emailVerified: true,
|
||||
name: "mockName",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
mockMasterPasswordService = new FakeMasterPasswordService();
|
||||
mockApiService = mock<UserKeyRotationApiService>();
|
||||
@ -69,7 +65,6 @@ describe("KeyRotationService", () => {
|
||||
mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||
mockCryptoService = mock<CryptoService>();
|
||||
mockEncryptService = mock<EncryptService>();
|
||||
mockStateService = mock<StateService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockKdfConfigService = mock<KdfConfigService>();
|
||||
mockSyncService = mock<SyncService>();
|
||||
@ -86,8 +81,6 @@ describe("KeyRotationService", () => {
|
||||
mockDeviceTrustService,
|
||||
mockCryptoService,
|
||||
mockEncryptService,
|
||||
mockStateService,
|
||||
mockAccountService,
|
||||
mockKdfConfigService,
|
||||
mockSyncService,
|
||||
mockWebauthnLoginAdminService,
|
||||
@ -98,91 +91,82 @@ describe("KeyRotationService", () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(keyRotationService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("rotateUserKeyAndEncryptedData", () => {
|
||||
let folderViews: BehaviorSubject<FolderView[]>;
|
||||
let sends: BehaviorSubject<Send[]>;
|
||||
let privateKey: BehaviorSubject<UserPrivateKey>;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any);
|
||||
mockCryptoService.makeUserKey.mockResolvedValue([
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
{
|
||||
encryptedString: "mockEncryptedUserKey",
|
||||
encryptedString: "mockNewUserKey",
|
||||
} as any,
|
||||
]);
|
||||
mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
// Mock private key
|
||||
mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any);
|
||||
mockCryptoService.userKey$.mockReturnValue(
|
||||
of(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
|
||||
);
|
||||
|
||||
// Mock ciphers
|
||||
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
|
||||
mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
|
||||
|
||||
// Mock folders
|
||||
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
|
||||
folderViews = new BehaviorSubject<FolderView[]>(mockFolders);
|
||||
mockFolderService.folderViews$ = folderViews;
|
||||
|
||||
// Mock sends
|
||||
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
|
||||
sends = new BehaviorSubject<Send[]>(mockSends);
|
||||
mockSendService.sends$ = sends;
|
||||
|
||||
mockWebauthnLoginAdminService.rotateWebAuthnKeys.mockResolvedValue([]);
|
||||
|
||||
// Mock encryption methods
|
||||
mockEncryptService.encrypt.mockResolvedValue({
|
||||
encryptedString: "mockEncryptedData",
|
||||
} as any);
|
||||
|
||||
mockFolderService.encrypt.mockImplementation((folder, userKey) => {
|
||||
const encryptedFolder = new Folder();
|
||||
encryptedFolder.id = folder.id;
|
||||
encryptedFolder.name = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"Encrypted: " + folder.name,
|
||||
);
|
||||
return Promise.resolve(encryptedFolder);
|
||||
});
|
||||
// Mock user key
|
||||
mockCryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
|
||||
|
||||
mockCipherService.encrypt.mockImplementation((cipher, userKey) => {
|
||||
const encryptedCipher = new Cipher();
|
||||
encryptedCipher.id = cipher.id;
|
||||
encryptedCipher.name = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"Encrypted: " + cipher.name,
|
||||
);
|
||||
return Promise.resolve(encryptedCipher);
|
||||
});
|
||||
// Mock private key
|
||||
privateKey = new BehaviorSubject("mockPrivateKey" as any);
|
||||
mockCryptoService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
|
||||
|
||||
// Mock ciphers
|
||||
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
|
||||
mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
|
||||
|
||||
// Mock folders
|
||||
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
|
||||
mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
|
||||
|
||||
// Mock sends
|
||||
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
|
||||
mockSendService.getRotatedData.mockResolvedValue(mockSends);
|
||||
|
||||
// Mock emergency access
|
||||
const emergencyAccess = [createMockEmergencyAccess("13")];
|
||||
mockEmergencyAccessService.getRotatedData.mockResolvedValue(emergencyAccess);
|
||||
|
||||
// Mock reset password
|
||||
const resetPassword = [createMockResetPassword("12")];
|
||||
mockResetPasswordService.getRotatedData.mockResolvedValue(resetPassword);
|
||||
|
||||
// Mock Webauthn
|
||||
const webauthn = [createMockWebauthn("13"), createMockWebauthn("14")];
|
||||
mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
|
||||
});
|
||||
|
||||
it("rotates the user key and encrypted data", async () => {
|
||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
|
||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
|
||||
|
||||
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
|
||||
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
|
||||
expect(arg.key).toBe("mockNewUserKey");
|
||||
expect(arg.privateKey).toBe("mockEncryptedData");
|
||||
expect(arg.ciphers.length).toBe(2);
|
||||
expect(arg.folders.length).toBe(2);
|
||||
expect(arg.sends.length).toBe(2);
|
||||
expect(arg.emergencyAccessKeys.length).toBe(1);
|
||||
expect(arg.resetPasswordKeys.length).toBe(1);
|
||||
expect(arg.webauthnKeys.length).toBe(2);
|
||||
});
|
||||
|
||||
it("throws if master password provided is falsey", async () => {
|
||||
await expect(keyRotationService.rotateUserKeyAndEncryptedData("")).rejects.toThrow();
|
||||
await expect(
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("", mockUser),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws if master key creation fails", async () => {
|
||||
mockCryptoService.makeMasterKey.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
@ -190,16 +174,24 @@ describe("KeyRotationService", () => {
|
||||
mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]);
|
||||
|
||||
await expect(
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws if no private key is found", async () => {
|
||||
privateKey.next(null);
|
||||
|
||||
await expect(
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("saves the master key in state after creation", async () => {
|
||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
|
||||
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
|
||||
|
||||
expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
|
||||
"mockMasterKey" as any,
|
||||
mockUserId,
|
||||
mockUser.id,
|
||||
);
|
||||
});
|
||||
|
||||
@ -207,30 +199,51 @@ describe("KeyRotationService", () => {
|
||||
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
|
||||
|
||||
await expect(
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"),
|
||||
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockFolder(id: string, name: string): FolderView {
|
||||
const folder = new FolderView();
|
||||
folder.id = id;
|
||||
folder.name = name;
|
||||
return folder;
|
||||
function createMockFolder(id: string, name: string): FolderWithIdRequest {
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
} as FolderWithIdRequest;
|
||||
}
|
||||
|
||||
function createMockCipher(id: string, name: string): CipherView {
|
||||
const cipher = new CipherView();
|
||||
cipher.id = id;
|
||||
cipher.name = name;
|
||||
cipher.type = CipherType.Login;
|
||||
return cipher;
|
||||
function createMockCipher(id: string, name: string): CipherWithIdRequest {
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
type: CipherType.Login,
|
||||
} as CipherWithIdRequest;
|
||||
}
|
||||
|
||||
function createMockSend(id: string, name: string): Send {
|
||||
const send = new Send();
|
||||
send.id = id;
|
||||
send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name);
|
||||
return send;
|
||||
function createMockSend(id: string, name: string): SendWithIdRequest {
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
} as SendWithIdRequest;
|
||||
}
|
||||
|
||||
function createMockEmergencyAccess(id: string): EmergencyAccessWithIdRequest {
|
||||
return {
|
||||
id: id,
|
||||
type: 0,
|
||||
waitTimeDays: 5,
|
||||
} as EmergencyAccessWithIdRequest;
|
||||
}
|
||||
|
||||
function createMockResetPassword(id: string): OrganizationUserResetPasswordWithIdRequest {
|
||||
return {
|
||||
organizationId: id,
|
||||
resetPasswordKey: "mockResetPasswordKey",
|
||||
} as OrganizationUserResetPasswordWithIdRequest;
|
||||
}
|
||||
|
||||
function createMockWebauthn(id: string): any {
|
||||
return {
|
||||
id: id,
|
||||
} as WebauthnRotateCredentialRequest;
|
||||
}
|
||||
|
@ -1,21 +1,19 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
|
||||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { WebauthnLoginAdminService } from "../core";
|
||||
@ -37,8 +35,6 @@ export class UserKeyRotationService {
|
||||
private deviceTrustService: DeviceTrustServiceAbstraction,
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private stateService: StateService,
|
||||
private accountService: AccountService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private syncService: SyncService,
|
||||
private webauthnLoginAdminService: WebauthnLoginAdminService,
|
||||
@ -48,7 +44,10 @@ export class UserKeyRotationService {
|
||||
* Creates a new user key and re-encrypts all required data with the it.
|
||||
* @param masterPassword current master password (used for validation)
|
||||
*/
|
||||
async rotateUserKeyAndEncryptedData(masterPassword: string): Promise<void> {
|
||||
async rotateUserKeyAndEncryptedData(
|
||||
masterPassword: string,
|
||||
user: { id: UserId } & AccountInfo,
|
||||
): Promise<void> {
|
||||
if (!masterPassword) {
|
||||
throw new Error("Invalid master password");
|
||||
}
|
||||
@ -62,7 +61,7 @@ export class UserKeyRotationService {
|
||||
// Create master key to validate the master password
|
||||
const masterKey = await this.cryptoService.makeMasterKey(
|
||||
masterPassword,
|
||||
await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))),
|
||||
user.email,
|
||||
await this.kdfConfigService.getKdfConfig(),
|
||||
);
|
||||
|
||||
@ -71,9 +70,7 @@ export class UserKeyRotationService {
|
||||
}
|
||||
|
||||
// Set master key again in case it was lost (could be lost on refresh)
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
const oldUserKey = await firstValueFrom(this.cryptoService.userKey$(userId));
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, user.id);
|
||||
const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey);
|
||||
|
||||
if (!newUserKey || !newEncUserKey) {
|
||||
@ -90,61 +87,86 @@ export class UserKeyRotationService {
|
||||
const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey);
|
||||
request.masterPasswordHash = masterPasswordHash;
|
||||
|
||||
// Get original user key
|
||||
// Note: We distribute the legacy key, but not all domains actually use it. If any of those
|
||||
// domains break their legacy support it will break the migration process for legacy users.
|
||||
const originalUserKey = await this.cryptoService.getUserKeyWithLegacySupport(user.id);
|
||||
|
||||
// Add re-encrypted data
|
||||
request.privateKey = await this.encryptPrivateKey(newUserKey);
|
||||
request.ciphers = await this.encryptCiphers(newUserKey);
|
||||
request.folders = await this.encryptFolders(newUserKey);
|
||||
request.sends = await this.sendService.getRotatedKeys(newUserKey);
|
||||
request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey);
|
||||
request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey);
|
||||
request.webauthnKeys = await this.webauthnLoginAdminService.rotateWebAuthnKeys(
|
||||
oldUserKey,
|
||||
request.privateKey = await this.encryptPrivateKey(newUserKey, user.id);
|
||||
|
||||
const rotatedCiphers = await this.cipherService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedCiphers != null) {
|
||||
request.ciphers = rotatedCiphers;
|
||||
}
|
||||
|
||||
const rotatedFolders = await this.folderService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedFolders != null) {
|
||||
request.folders = rotatedFolders;
|
||||
}
|
||||
|
||||
const rotatedSends = await this.sendService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedSends != null) {
|
||||
request.sends = rotatedSends;
|
||||
}
|
||||
|
||||
const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedEmergencyAccessKeys != null) {
|
||||
request.emergencyAccessKeys = rotatedEmergencyAccessKeys;
|
||||
}
|
||||
|
||||
// Note: Reset password keys request model has user verification
|
||||
// properties, but the rotation endpoint uses its own MP hash.
|
||||
const rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedResetPasswordKeys != null) {
|
||||
request.resetPasswordKeys = rotatedResetPasswordKeys;
|
||||
}
|
||||
|
||||
const rotatedWebauthnKeys = await this.webauthnLoginAdminService.getRotatedData(
|
||||
originalUserKey,
|
||||
newUserKey,
|
||||
user.id,
|
||||
);
|
||||
if (rotatedWebauthnKeys != null) {
|
||||
request.webauthnKeys = rotatedWebauthnKeys;
|
||||
}
|
||||
|
||||
await this.apiService.postUserKeyUpdate(request);
|
||||
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.deviceTrustService.rotateDevicesTrust(
|
||||
activeAccount.id,
|
||||
newUserKey,
|
||||
masterPasswordHash,
|
||||
);
|
||||
// TODO PM-2199: Add device trust rotation support to the user key rotation endpoint
|
||||
await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash);
|
||||
}
|
||||
|
||||
private async encryptPrivateKey(newUserKey: UserKey): Promise<EncryptedString | null> {
|
||||
const privateKey = await this.cryptoService.getPrivateKey();
|
||||
private async encryptPrivateKey(
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<EncryptedString | null> {
|
||||
const privateKey = await firstValueFrom(
|
||||
this.cryptoService.userPrivateKeyWithLegacySupport$(userId),
|
||||
);
|
||||
if (!privateKey) {
|
||||
return;
|
||||
throw new Error("No private key found for user key rotation");
|
||||
}
|
||||
return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString;
|
||||
}
|
||||
|
||||
private async encryptCiphers(newUserKey: UserKey): Promise<CipherWithIdRequest[]> {
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
if (!ciphers) {
|
||||
// Must return an empty array for backwards compatibility
|
||||
return [];
|
||||
}
|
||||
return await Promise.all(
|
||||
ciphers.map(async (cipher) => {
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey);
|
||||
return new CipherWithIdRequest(encryptedCipher);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async encryptFolders(newUserKey: UserKey): Promise<FolderWithIdRequest[]> {
|
||||
const folders = await firstValueFrom(this.folderService.folderViews$);
|
||||
if (!folders) {
|
||||
// Must return an empty array for backwards compatibility
|
||||
return [];
|
||||
}
|
||||
return await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
const encryptedFolder = await this.folderService.encrypt(folder, newUserKey);
|
||||
return new FolderWithIdRequest(encryptedFolder);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@ -25,6 +27,7 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||
});
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private keyRotationService: UserKeyRotationService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
@ -41,7 +44,9 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUserKey = await this.cryptoService.hasUserKey();
|
||||
const activeUser = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
const hasUserKey = await this.cryptoService.hasUserKey(activeUser.id);
|
||||
if (hasUserKey) {
|
||||
this.messagingService.send("logout");
|
||||
throw new Error("User key already exists, cannot migrate legacy encryption.");
|
||||
@ -52,7 +57,7 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||
try {
|
||||
await this.syncService.fullSync(false, true);
|
||||
|
||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword);
|
||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
|
@ -14,7 +14,7 @@
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="primary"
|
||||
class="tw-w-full"
|
||||
class="tw-w-full tw-mb-2"
|
||||
[bitAction]="convert"
|
||||
[block]="true"
|
||||
>
|
||||
|
@ -4,9 +4,6 @@
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
{{ "loading" | i18n }}
|
||||
</div>
|
||||
<app-callout type="error" *ngIf="error">
|
||||
{{ error }}
|
||||
</app-callout>
|
||||
<p class="tw-text-lg">{{ "pickAnAvatarColor" | i18n }}</p>
|
||||
<div class="tw-flex tw-flex-wrap tw-justify-center tw-gap-8">
|
||||
<ng-container *ngFor="let c of defaultColorPalette">
|
||||
|
@ -31,7 +31,6 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
|
||||
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
|
||||
|
||||
loading = false;
|
||||
error: string;
|
||||
defaultColorPalette: NamedAvatarColor[] = [
|
||||
{ name: "brightBlue", color: "#16cbfc" },
|
||||
{ name: "green", color: "#94cc4b" },
|
||||
|
@ -1,7 +1,7 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<app-callout type="warning" *ngIf="showTwoFactorEmailWarning">
|
||||
<bit-callout type="warning" *ngIf="showTwoFactorEmailWarning">
|
||||
{{ "changeEmailTwoFactorWarning" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<div class="tw-w-1/2 tw-pr-2" formGroupName="step1">
|
||||
<bit-form-field>
|
||||
@ -29,7 +29,7 @@
|
||||
<ng-container *ngIf="tokenSent">
|
||||
<hr />
|
||||
<p>{{ "changeEmailDesc" | i18n: formGroup.controls.step1.value.newEmail }}</p>
|
||||
<app-callout type="warning">{{ "loggedOutWarning" | i18n }}</app-callout>
|
||||
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
|
||||
|
||||
<div class="tw-w-1/2 tw-pr-2">
|
||||
<bit-form-field>
|
||||
|
@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ "deauthorizeSessionsDesc" | i18n }}</p>
|
||||
<app-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</app-callout>
|
||||
<bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
|
||||
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
|
||||
</app-user-verification>
|
||||
</div>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<bit-dialog dialogSize="default" [title]="'deleteAccount' | i18n">
|
||||
<ng-container bitDialogContent>
|
||||
<p bitTypography="body1">{{ "deleteAccountDesc" | i18n }}</p>
|
||||
<app-callout type="warning">{{ "deleteAccountWarning" | i18n }}</app-callout>
|
||||
<bit-callout type="warning">{{ "deleteAccountWarning" | i18n }}</bit-callout>
|
||||
<app-user-verification-form-input
|
||||
formControlName="verification"
|
||||
name="verification"
|
||||
|
@ -220,6 +220,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent {
|
||||
}
|
||||
|
||||
private async updateKey() {
|
||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword);
|
||||
const user = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
<small class="tw-text-muted" *ngIf="params.name">{{ params.name }}</small>
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<app-callout type="warning">{{ "loggedOutWarning" | i18n }}</app-callout>
|
||||
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>
|
||||
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
|
||||
</auth-password-callout>
|
||||
<div class="tw-w-full tw-flex tw-gap-4">
|
||||
|
@ -5,11 +5,11 @@
|
||||
<p bitTypography="body1">{{ data.apiKeyDescription | i18n }}</p>
|
||||
<app-user-verification-form-input formControlName="masterPassword" *ngIf="!clientSecret">
|
||||
</app-user-verification-form-input>
|
||||
<app-callout type="warning" *ngIf="clientSecret">{{ data.apiKeyWarning | i18n }}</app-callout>
|
||||
<app-callout
|
||||
<bit-callout type="warning" *ngIf="clientSecret">{{ data.apiKeyWarning | i18n }}</bit-callout>
|
||||
<bit-callout
|
||||
type="info"
|
||||
title="{{ 'oauth2ClientCredentials' | i18n }}"
|
||||
icon="bwi bwi-key"
|
||||
icon="bwi-key"
|
||||
*ngIf="clientSecret"
|
||||
>
|
||||
<p bitTypography="body1" class="tw-mb-1">
|
||||
@ -28,7 +28,7 @@
|
||||
<strong>grant_type:</strong><br />
|
||||
<code>{{ data.grantType }}</code>
|
||||
</p>
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
<button type="submit" buttonType="primary" *ngIf="!clientSecret" bitButton bitFormButton>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<form *ngIf="authed" [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="default">
|
||||
<span bitDialogTitle
|
||||
>{{ "twoStepLogin" | i18n }}
|
||||
<span bitDialogTitle>
|
||||
{{ "twoStepLogin" | i18n }}
|
||||
<span bitTypography="body1">{{ "authenticatorAppTitle" | i18n }}</span>
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
@ -13,10 +13,10 @@
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="enabled">
|
||||
<app-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi bwi-check-circle">
|
||||
<bit-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi-check-circle">
|
||||
<p bitTypography="body1">{{ "twoStepLoginProviderEnabled" | i18n }}</p>
|
||||
{{ "twoStepAuthenticatorReaddDesc" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<img class="float-right mfaType0" alt="Authenticator app logo" />
|
||||
<p bitTypography="body1">{{ "twoStepAuthenticatorNeedApp" | i18n }}</p>
|
||||
</ng-container>
|
||||
|
@ -6,9 +6,9 @@
|
||||
</span>
|
||||
<ng-container bitDialogContent>
|
||||
<ng-container *ngIf="enabled">
|
||||
<app-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi bwi-check-circle">
|
||||
<bit-callout type="success" title="{{ 'enabled' | i18n }}" icon="bwi-check-circle">
|
||||
{{ "twoStepLoginProviderEnabled" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<strong>{{ "email" | i18n }}:</strong> {{ email }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!enabled">
|
||||
|
@ -455,7 +455,9 @@
|
||||
</ng-container>
|
||||
</bit-section>
|
||||
<bit-section *ngIf="singleOrgPolicyBlock">
|
||||
<app-callout [type]="'error'">{{ "singleOrgBlockCreateMessage" | i18n }}</app-callout>
|
||||
<bit-callout type="danger" [title]="'error' | i18n">
|
||||
{{ "singleOrgBlockCreateMessage" | i18n }}
|
||||
</bit-callout>
|
||||
</bit-section>
|
||||
<bit-section>
|
||||
<button
|
||||
|
@ -63,9 +63,9 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="showMethods && method === paymentMethodType.BankAccount">
|
||||
<app-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
<bit-callout type="warning" title="{{ 'verifyBankAccount' | i18n }}">
|
||||
{{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4" formGroupName="bank">
|
||||
<bit-form-field class="tw-col-span-6">
|
||||
<bit-label>{{ "routingNumber" | i18n }}</bit-label>
|
||||
@ -106,8 +106,8 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="showMethods && method === paymentMethodType.Credit">
|
||||
<app-callout type="note">
|
||||
<bit-callout>
|
||||
{{ "makeSureEnoughCredit" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<bit-container>
|
||||
<p bitTypography="body1">{{ "preferencesDesc" | i18n }}</p>
|
||||
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-1/2">
|
||||
<app-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
|
||||
<bit-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
|
||||
<span *ngIf="policy.timeout && policy.action">
|
||||
{{
|
||||
"vaultTimeoutPolicyWithActionInEffect"
|
||||
@ -16,7 +16,7 @@
|
||||
<span *ngIf="!policy.timeout && policy.action">
|
||||
{{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }}
|
||||
</span>
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<app-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
|
@ -20,9 +20,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" *ngIf="cipher">
|
||||
<app-callout type="info" *ngIf="allowOwnershipAssignment() && !allowPersonal">
|
||||
<bit-callout type="info" *ngIf="allowOwnershipAssignment() && !allowPersonal">
|
||||
{{ "personalOwnershipPolicyInEffect" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<div class="row" *ngIf="!editMode && !viewOnly">
|
||||
<div class="col-6 form-group">
|
||||
<label for="type">{{ "whatTypeOfItem" | i18n }}</label>
|
||||
|
@ -94,6 +94,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
async ngOnInit() {
|
||||
await super.ngOnInit();
|
||||
await this.load();
|
||||
this.viewOnly = !this.cipher.edit && this.editMode;
|
||||
// remove when all the title for all clients are updated to New Item
|
||||
if (this.cloneMode || !this.editMode) {
|
||||
this.title = this.i18nService.t("newItem");
|
||||
|
@ -33,9 +33,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
|
||||
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi-exclamation-triangle">
|
||||
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
|
||||
{{ trashCleanupWarning }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<app-vault-items
|
||||
[ciphers]="ciphers"
|
||||
[collections]="collections"
|
||||
|
@ -41,9 +41,9 @@
|
||||
{{ "addAccess" | i18n }}
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
<app-callout type="warning" *ngIf="activeFilter.isDeleted" icon="bwi bwi-exclamation-triangle">
|
||||
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
|
||||
{{ trashCleanupWarning }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
<app-vault-items
|
||||
[ciphers]="ciphers"
|
||||
[collections]="collections"
|
||||
|
@ -4,7 +4,7 @@
|
||||
<p bitTypography="body1">
|
||||
{{ (organizationId ? "purgeOrgVaultDesc" : "purgeVaultDesc") | i18n }}
|
||||
</p>
|
||||
<app-callout type="warning">{{ "purgeVaultWarning" | i18n }}</app-callout>
|
||||
<bit-callout type="warning">{{ "purgeVaultWarning" | i18n }}</bit-callout>
|
||||
<app-user-verification formControlName="masterPassword"></app-user-verification>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
|
@ -8429,5 +8429,23 @@
|
||||
},
|
||||
"providerReinstate":{
|
||||
"message": " Contact Customer Support to reinstate your subscription."
|
||||
},
|
||||
"secretPeopleDescription": {
|
||||
"message": "Grant groups or people access to this secret. Permissions set for people will override permissions set by groups."
|
||||
},
|
||||
"secretPeopleEmptyMessage": {
|
||||
"message": "Add people or groups to share access to this secret"
|
||||
},
|
||||
"secretMachineAccountsDescription": {
|
||||
"message": "Grant machine accounts access to this secret."
|
||||
},
|
||||
"secretMachineAccountsEmptyMessage": {
|
||||
"message": "Add machine accounts to grant access to this secret"
|
||||
},
|
||||
"smAccessRemovalWarningSecretTitle": {
|
||||
"message": "Remove access to this secret"
|
||||
},
|
||||
"smAccessRemovalSecretMessage": {
|
||||
"message": "This action will remove your access to this secret."
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,9 @@
|
||||
"@bitwarden/components": ["../../libs/components/src"],
|
||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": [
|
||||
"../../libs/tools/export/vault-export/vault-export-core/src"
|
||||
],
|
||||
|
@ -13,7 +13,9 @@
|
||||
"@bitwarden/components": ["../../libs/components/src"],
|
||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": [
|
||||
"../../libs/tools/export/vault-export/vault-export-core/src"
|
||||
],
|
||||
|
@ -1,9 +1,9 @@
|
||||
<app-callout type="warning">
|
||||
<bit-callout type="warning">
|
||||
{{ "experimentalFeature" | i18n }}
|
||||
<a href="https://bitwarden.com/help/auto-fill-browser/" target="_blank" rel="noreferrer">{{
|
||||
"learnMoreAboutAutofill" | i18n
|
||||
}}</a>
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-callout type="tip" title="{{ 'prerequisite' | i18n }}">
|
||||
<bit-callout title="{{ 'prerequisite' | i18n }}">
|
||||
{{ "requireSsoPolicyReq" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
|
||||
|
@ -106,9 +106,9 @@
|
||||
showKeyConnectorOptions
|
||||
"
|
||||
>
|
||||
<app-callout type="warning" [useAlertRole]="true">
|
||||
<bit-callout type="warning" [useAlertRole]="true">
|
||||
{{ "keyConnectorWarning" | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "keyConnectorUrl" | i18n }}</bit-label>
|
||||
|
@ -1,71 +1,102 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large">
|
||||
<ng-container bitDialogTitle>{{ title | i18n }}</ng-container>
|
||||
<bit-dialog
|
||||
dialogSize="large"
|
||||
[disablePadding]="true"
|
||||
[loading]="loading"
|
||||
[title]="title | i18n"
|
||||
[subtitle]="subtitle"
|
||||
>
|
||||
<div bitDialogContent class="tw-relative">
|
||||
<div
|
||||
*ngIf="showSpinner"
|
||||
class="tw-absolute tw-flex tw-h-full tw-w-full tw-items-center tw-justify-center tw-bg-text-contrast"
|
||||
>
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-pt-4">
|
||||
<bit-form-field class="tw-w-1/3">
|
||||
<bit-label for="secret-name">{{ "name" | i18n }}</bit-label>
|
||||
<input appAutofocus formControlName="name" bitInput />
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-w-full">
|
||||
<bit-label>{{ "value" | i18n }}</bit-label>
|
||||
<textarea bitInput rows="4" formControlName="value"></textarea>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "notes" | i18n }}</bit-label>
|
||||
<textarea bitInput rows="4" formControlName="notes"></textarea>
|
||||
</bit-form-field>
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex">
|
||||
<bit-tab label="{{ 'nameValuePair' | i18n }}">
|
||||
<div class="tw-flex tw-gap-4 tw-pt-4">
|
||||
<bit-form-field class="tw-w-1/3">
|
||||
<bit-label>{{ "name" | i18n }}</bit-label>
|
||||
<input appAutofocus formControlName="name" bitInput />
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-w-full">
|
||||
<bit-label>{{ "value" | i18n }}</bit-label>
|
||||
<textarea bitInput rows="4" formControlName="value"></textarea>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "notes" | i18n }}</bit-label>
|
||||
<textarea bitInput rows="4" formControlName="notes"></textarea>
|
||||
</bit-form-field>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<bit-form-field class="tw-mb-0 tw-mt-3">
|
||||
<bit-label>{{ "project" | i18n }}</bit-label>
|
||||
<bit-select bitInput name="project" formControlName="project">
|
||||
<bit-option value="" [label]="'selectPlaceholder' | i18n"></bit-option>
|
||||
<bit-option
|
||||
*ngFor="let p of projects"
|
||||
[icon]="p.id === this.newProjectGuid ? 'bwi-plus-circle' : ''"
|
||||
[value]="p.id"
|
||||
[label]="p.name"
|
||||
<bit-form-field class="tw-mb-0 tw-mt-3">
|
||||
<bit-label>{{ "project" | i18n }}</bit-label>
|
||||
<bit-select bitInput name="project" formControlName="project">
|
||||
<bit-option value="" [label]="'selectPlaceholder' | i18n"></bit-option>
|
||||
<bit-option
|
||||
*ngFor="let p of projects"
|
||||
[icon]="p.id === this.newProjectGuid ? 'bwi-plus-circle' : ''"
|
||||
[value]="p.id"
|
||||
[label]="p.name"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field *ngIf="addNewProject == true">
|
||||
<bit-label>{{ "projectName" | i18n }}</bit-label>
|
||||
<input formControlName="newProjectName" bitInput />
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
|
||||
<bit-tab label="{{ 'people' | i18n }}">
|
||||
<p>
|
||||
{{ "secretPeopleDescription" | i18n }}
|
||||
</p>
|
||||
<sm-access-policy-selector
|
||||
formControlName="peopleAccessPolicies"
|
||||
[addButtonMode]="false"
|
||||
[items]="peopleAccessPolicyItems"
|
||||
[label]="'people' | i18n"
|
||||
[hint]="'projectPeopleSelectHint' | i18n"
|
||||
[columnTitle]="'name' | i18n"
|
||||
[emptyMessage]="'secretPeopleEmptyMessage' | i18n"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</sm-access-policy-selector>
|
||||
</bit-tab>
|
||||
|
||||
<bit-form-field *ngIf="addNewProject == true">
|
||||
<bit-label>{{ "projectName" | i18n }}</bit-label>
|
||||
<input formControlName="newProjectName" bitInput />
|
||||
</bit-form-field>
|
||||
<bit-tab label="{{ 'machineAccounts' | i18n }}">
|
||||
<p>
|
||||
{{ "secretMachineAccountsDescription" | i18n }}
|
||||
</p>
|
||||
<sm-access-policy-selector
|
||||
formControlName="serviceAccountAccessPolicies"
|
||||
[addButtonMode]="false"
|
||||
[items]="serviceAccountAccessPolicyItems"
|
||||
[label]="'machineAccounts' | i18n"
|
||||
[hint]="'projectMachineAccountsSelectHint' | i18n"
|
||||
[columnTitle]="'machineAccounts' | i18n"
|
||||
[emptyMessage]="'secretMachineAccountsEmptyMessage' | i18n"
|
||||
>
|
||||
</sm-access-policy-selector>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton buttonType="primary" bitFormButton>
|
||||
<button [loading]="loading" type="submit" bitButton buttonType="primary" bitFormButton>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
bitFormButton
|
||||
bitDialogClose
|
||||
[disabled]="false"
|
||||
>
|
||||
<button bitButton buttonType="secondary" type="button" bitDialogClose>
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="deleteButtonIsVisible"
|
||||
class="tw-ml-auto"
|
||||
[disabled]="loading"
|
||||
type="button"
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
bitIconButton="bwi-trash"
|
||||
bitFormButton
|
||||
(click)="openDeleteSecretDialog()"
|
||||
[appA11yTitle]="'delete' | i18n"
|
||||
[bitAction]="delete"
|
||||
></button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnInit } from "@angular/core";
|
||||
import { ChangeDetectorRef, Component, Inject, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { lastValueFrom, Subject, takeUntil } from "rxjs";
|
||||
|
||||
@ -9,12 +9,25 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DialogService, BitValidators } from "@bitwarden/components";
|
||||
|
||||
import { SecretAccessPoliciesView } from "../../models/view/access-policies/secret-access-policies.view";
|
||||
import { ProjectListView } from "../../models/view/project-list.view";
|
||||
import { ProjectView } from "../../models/view/project.view";
|
||||
import { SecretListView } from "../../models/view/secret-list.view";
|
||||
import { SecretProjectView } from "../../models/view/secret-project.view";
|
||||
import { SecretView } from "../../models/view/secret.view";
|
||||
import { ProjectService } from "../../projects/project.service";
|
||||
import { AccessPolicySelectorService } from "../../shared/access-policies/access-policy-selector/access-policy-selector.service";
|
||||
import {
|
||||
ApItemValueType,
|
||||
convertToSecretAccessPoliciesView,
|
||||
} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type";
|
||||
import {
|
||||
ApItemViewType,
|
||||
convertPotentialGranteesToApItemViewType,
|
||||
convertSecretAccessPoliciesToApItemViews,
|
||||
} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
|
||||
import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-item.enum";
|
||||
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
|
||||
import { SecretService } from "../secret.service";
|
||||
|
||||
import { SecretDeleteDialogComponent, SecretDeleteOperation } from "./secret-delete.component";
|
||||
@ -24,6 +37,12 @@ export enum OperationType {
|
||||
Edit,
|
||||
}
|
||||
|
||||
export enum SecretDialogTabType {
|
||||
NameValuePair = 0,
|
||||
People = 1,
|
||||
ServiceAccounts = 2,
|
||||
}
|
||||
|
||||
export interface SecretOperation {
|
||||
organizationId: string;
|
||||
operation: OperationType;
|
||||
@ -36,6 +55,12 @@ export interface SecretOperation {
|
||||
templateUrl: "./secret-dialog.component.html",
|
||||
})
|
||||
export class SecretDialogComponent implements OnInit {
|
||||
loading = true;
|
||||
projects: ProjectListView[];
|
||||
addNewProject = false;
|
||||
newProjectGuid = Utils.newGuid();
|
||||
tabIndex: SecretDialogTabType = SecretDialogTabType.NameValuePair;
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
name: new FormControl("", {
|
||||
validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator],
|
||||
@ -51,77 +76,60 @@ export class SecretDialogComponent implements OnInit {
|
||||
validators: [Validators.maxLength(500), BitValidators.trimValidator],
|
||||
updateOn: "submit",
|
||||
}),
|
||||
peopleAccessPolicies: new FormControl([] as ApItemValueType[]),
|
||||
serviceAccountAccessPolicies: new FormControl([] as ApItemValueType[]),
|
||||
});
|
||||
protected peopleAccessPolicyItems: ApItemViewType[];
|
||||
protected serviceAccountAccessPolicyItems: ApItemViewType[];
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private loading = true;
|
||||
projects: ProjectListView[];
|
||||
addNewProject = false;
|
||||
newProjectGuid = Utils.newGuid();
|
||||
private currentPeopleAccessPolicies: ApItemViewType[];
|
||||
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: SecretOperation,
|
||||
private secretService: SecretService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private projectService: ProjectService,
|
||||
private dialogService: DialogService,
|
||||
private organizationService: OrganizationService,
|
||||
private accessPolicyService: AccessPolicyService,
|
||||
private accessPolicySelectorService: AccessPolicySelectorService,
|
||||
) {}
|
||||
|
||||
get title() {
|
||||
return this.data.operation === OperationType.Add ? "newSecret" : "editSecret";
|
||||
}
|
||||
|
||||
get subtitle(): string | undefined {
|
||||
if (this.data.operation === OperationType.Edit) {
|
||||
return this.formGroup.get("name").value;
|
||||
}
|
||||
}
|
||||
|
||||
get deleteButtonIsVisible(): boolean {
|
||||
return this.data.operation === OperationType.Edit;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.loading = true;
|
||||
if (this.data.operation === OperationType.Edit && this.data.secretId) {
|
||||
await this.loadData();
|
||||
await this.loadEditDialog();
|
||||
} else if (this.data.operation !== OperationType.Add) {
|
||||
this.dialogRef.close();
|
||||
throw new Error(`The secret dialog was not called with the appropriate operation values.`);
|
||||
} else if (this.data.operation == OperationType.Add) {
|
||||
await this.loadProjects(true);
|
||||
if (this.data.projectId == null || this.data.projectId == "") {
|
||||
this.addNewProjectOptionToProjectsDropDown();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.data.projectId) {
|
||||
this.formGroup.get("project").setValue(this.data.projectId);
|
||||
} else if (this.data.operation === OperationType.Add) {
|
||||
await this.loadAddDialog();
|
||||
}
|
||||
|
||||
if ((await this.organizationService.get(this.data.organizationId))?.isAdmin) {
|
||||
this.formGroup.get("project").removeValidators(Validators.required);
|
||||
this.formGroup.get("project").updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
this.formGroup.disable();
|
||||
const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId);
|
||||
|
||||
await this.loadProjects(secret.write);
|
||||
|
||||
this.formGroup.setValue({
|
||||
name: secret.name,
|
||||
value: secret.value,
|
||||
notes: secret.note,
|
||||
project: secret.projects[0]?.id ?? "",
|
||||
newProjectName: "",
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
|
||||
if (secret.write) {
|
||||
this.formGroup.enable();
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjects(filterByPermission: boolean) {
|
||||
this.projects = await this.projectService
|
||||
.getProjects(this.data.organizationId)
|
||||
.then((projects) => projects.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
if (filterByPermission) {
|
||||
this.projects = this.projects.filter((p) => p.write);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@ -129,6 +137,152 @@ export class SecretDialogComponent implements OnInit {
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (!this.data.organizationEnabled) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("secretsCannotCreate"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isFormInvalid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretView = this.getSecretView();
|
||||
const secretAccessPoliciesView = convertToSecretAccessPoliciesView([
|
||||
...this.formGroup.value.peopleAccessPolicies,
|
||||
...this.formGroup.value.serviceAccountAccessPolicies,
|
||||
]);
|
||||
|
||||
const showAccessRemovalWarning =
|
||||
this.data.operation === OperationType.Edit &&
|
||||
(await this.accessPolicySelectorService.showSecretAccessRemovalWarning(
|
||||
this.data.organizationId,
|
||||
this.currentPeopleAccessPolicies,
|
||||
this.formGroup.value.peopleAccessPolicies,
|
||||
));
|
||||
|
||||
if (showAccessRemovalWarning) {
|
||||
const confirmed = await this.showWarning();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.addNewProject) {
|
||||
const newProject = await this.createProject(this.getNewProjectView());
|
||||
secretView.projects = [newProject];
|
||||
}
|
||||
|
||||
if (this.data.operation === OperationType.Add) {
|
||||
await this.createSecret(secretView, secretAccessPoliciesView);
|
||||
} else {
|
||||
secretView.id = this.data.secretId;
|
||||
await this.updateSecret(secretView, secretAccessPoliciesView);
|
||||
}
|
||||
this.dialogRef.close();
|
||||
};
|
||||
|
||||
delete = async () => {
|
||||
const secretListView: SecretListView[] = this.getSecretListView();
|
||||
|
||||
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
|
||||
SecretDeleteDialogComponent,
|
||||
{
|
||||
data: {
|
||||
secrets: secretListView,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await lastValueFrom(dialogRef.closed).then(
|
||||
(closeData) => closeData !== undefined && this.dialogRef.close(),
|
||||
);
|
||||
};
|
||||
|
||||
private async loadEditDialog() {
|
||||
const secret = await this.secretService.getBySecretId(this.data.secretId);
|
||||
await this.loadProjects(secret.projects);
|
||||
|
||||
const currentAccessPolicies = await this.getCurrentAccessPolicies(
|
||||
this.data.organizationId,
|
||||
this.data.secretId,
|
||||
);
|
||||
this.currentPeopleAccessPolicies = currentAccessPolicies.filter(
|
||||
(p) => p.type === ApItemEnum.User || p.type === ApItemEnum.Group,
|
||||
);
|
||||
const currentServiceAccountPolicies = currentAccessPolicies.filter(
|
||||
(p) => p.type === ApItemEnum.ServiceAccount,
|
||||
);
|
||||
|
||||
this.peopleAccessPolicyItems = await this.getPeoplePotentialGrantees(this.data.organizationId);
|
||||
this.serviceAccountAccessPolicyItems = await this.getServiceAccountItems(
|
||||
this.data.organizationId,
|
||||
currentServiceAccountPolicies,
|
||||
);
|
||||
|
||||
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
|
||||
// potentialGrantees, otherwise no selected values will be patched below
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
this.formGroup.patchValue({
|
||||
name: secret.name,
|
||||
value: secret.value,
|
||||
notes: secret.note,
|
||||
project: secret.projects[0]?.id ?? "",
|
||||
newProjectName: "",
|
||||
peopleAccessPolicies: this.currentPeopleAccessPolicies.map((m) => ({
|
||||
type: m.type,
|
||||
id: m.id,
|
||||
permission: m.permission,
|
||||
currentUser: m.type === ApItemEnum.User ? m.currentUser : null,
|
||||
currentUserInGroup: m.type === ApItemEnum.Group ? m.currentUserInGroup : null,
|
||||
})),
|
||||
serviceAccountAccessPolicies: currentServiceAccountPolicies.map((m) => ({
|
||||
type: m.type,
|
||||
id: m.id,
|
||||
permission: m.permission,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private async loadAddDialog() {
|
||||
await this.loadProjects();
|
||||
this.peopleAccessPolicyItems = await this.getPeoplePotentialGrantees(this.data.organizationId);
|
||||
this.serviceAccountAccessPolicyItems = await this.getServiceAccountItems(
|
||||
this.data.organizationId,
|
||||
);
|
||||
|
||||
if (
|
||||
this.data.projectId === null ||
|
||||
this.data.projectId === "" ||
|
||||
this.data.projectId === undefined
|
||||
) {
|
||||
this.addNewProjectOptionToProjectsDropDown();
|
||||
}
|
||||
|
||||
if (this.data.projectId) {
|
||||
this.formGroup.get("project").setValue(this.data.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProjects(currentProjects?: SecretProjectView[]) {
|
||||
this.projects = await this.projectService
|
||||
.getProjects(this.data.organizationId)
|
||||
.then((projects) => projects.filter((p) => p.write));
|
||||
|
||||
if (currentProjects?.length > 0) {
|
||||
const currentProject = currentProjects?.[0];
|
||||
if (this.projects.find((p) => p.id === currentProject.id) === undefined) {
|
||||
const listView = new ProjectListView();
|
||||
listView.id = currentProject.id;
|
||||
listView.name = currentProject.name;
|
||||
this.projects.push(listView);
|
||||
}
|
||||
}
|
||||
|
||||
this.projects = this.projects.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private addNewProjectOptionToProjectsDropDown() {
|
||||
this.formGroup
|
||||
.get("project")
|
||||
@ -155,70 +309,15 @@ export class SecretDialogComponent implements OnInit {
|
||||
this.formGroup.get("newProjectName").updateValueAndValidity();
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.data.operation === OperationType.Add ? "newSecret" : "editSecret";
|
||||
}
|
||||
|
||||
get showSpinner() {
|
||||
return this.data.operation === OperationType.Edit && this.loading;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (!this.data.organizationEnabled) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("secretsCannotCreate"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretView = this.getSecretView();
|
||||
|
||||
if (this.addNewProject) {
|
||||
const newProject = await this.createProject(this.getNewProjectView());
|
||||
secretView.projects = [newProject];
|
||||
}
|
||||
|
||||
if (this.data.operation === OperationType.Add) {
|
||||
await this.createSecret(secretView);
|
||||
} else {
|
||||
secretView.id = this.data.secretId;
|
||||
await this.updateSecret(secretView);
|
||||
}
|
||||
this.dialogRef.close();
|
||||
};
|
||||
|
||||
get deleteButtonIsVisible(): boolean {
|
||||
return this.data.operation === OperationType.Edit;
|
||||
}
|
||||
|
||||
private async createProject(projectView: ProjectView) {
|
||||
return await this.projectService.create(this.data.organizationId, projectView);
|
||||
}
|
||||
|
||||
protected async openDeleteSecretDialog() {
|
||||
const secretListView: SecretListView[] = this.getSecretListView();
|
||||
|
||||
const dialogRef = this.dialogService.open<unknown, SecretDeleteOperation>(
|
||||
SecretDeleteDialogComponent,
|
||||
{
|
||||
data: {
|
||||
secrets: secretListView,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// If the secret is deleted, chain close this dialog after the delete dialog
|
||||
await lastValueFrom(dialogRef.closed).then(
|
||||
(closeData) => closeData !== undefined && this.dialogRef.close(),
|
||||
);
|
||||
}
|
||||
|
||||
private async createSecret(secretView: SecretView) {
|
||||
await this.secretService.create(this.data.organizationId, secretView);
|
||||
private async createSecret(
|
||||
secretView: SecretView,
|
||||
secretAccessPoliciesView: SecretAccessPoliciesView,
|
||||
) {
|
||||
await this.secretService.create(this.data.organizationId, secretView, secretAccessPoliciesView);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("secretCreated"));
|
||||
}
|
||||
|
||||
@ -229,8 +328,11 @@ export class SecretDialogComponent implements OnInit {
|
||||
return projectView;
|
||||
}
|
||||
|
||||
private async updateSecret(secretView: SecretView) {
|
||||
await this.secretService.update(this.data.organizationId, secretView);
|
||||
private async updateSecret(
|
||||
secretView: SecretView,
|
||||
secretAccessPoliciesView: SecretAccessPoliciesView,
|
||||
) {
|
||||
await this.secretService.update(this.data.organizationId, secretView, secretAccessPoliciesView);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("secretEdited"));
|
||||
}
|
||||
|
||||
@ -265,4 +367,63 @@ export class SecretDialogComponent implements OnInit {
|
||||
secretListViews.push(secretListView);
|
||||
return secretListViews;
|
||||
}
|
||||
|
||||
private async getCurrentAccessPolicies(
|
||||
organizationId: string,
|
||||
secretId: string,
|
||||
): Promise<ApItemViewType[]> {
|
||||
return convertSecretAccessPoliciesToApItemViews(
|
||||
await this.accessPolicyService.getSecretAccessPolicies(organizationId, secretId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getPeoplePotentialGrantees(organizationId: string): Promise<ApItemViewType[]> {
|
||||
return convertPotentialGranteesToApItemViewType(
|
||||
await this.accessPolicyService.getPeoplePotentialGrantees(organizationId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getServiceAccountItems(
|
||||
organizationId: string,
|
||||
currentAccessPolicies?: ApItemViewType[],
|
||||
): Promise<ApItemViewType[]> {
|
||||
const potentialGrantees = convertPotentialGranteesToApItemViewType(
|
||||
await this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId),
|
||||
);
|
||||
const items = [...potentialGrantees];
|
||||
if (currentAccessPolicies) {
|
||||
for (const policy of currentAccessPolicies) {
|
||||
const exists = potentialGrantees.some((grantee) => grantee.id === policy.id);
|
||||
if (!exists) {
|
||||
items.push(policy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private async showWarning(): Promise<boolean> {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "smAccessRemovalWarningSecretTitle" },
|
||||
content: { key: "smAccessRemovalSecretMessage" },
|
||||
acceptButtonText: { key: "removeAccess" },
|
||||
cancelButtonText: { key: "cancel" },
|
||||
type: "warning",
|
||||
});
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
private isFormInvalid(): boolean {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid && this.tabIndex !== SecretDialogTabType.NameValuePair) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("nameValuePair")),
|
||||
);
|
||||
}
|
||||
|
||||
return this.formGroup.invalid;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { SecretAccessPoliciesRequest } from "../../shared/access-policies/models/requests/secret-access-policies.request";
|
||||
|
||||
export class SecretRequest {
|
||||
key: string;
|
||||
value: string;
|
||||
note: string;
|
||||
projectIds?: string[];
|
||||
accessPoliciesRequests: SecretAccessPoliciesRequest;
|
||||
}
|
||||
|
@ -0,0 +1,125 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { SecretAccessPoliciesView } from "../models/view/access-policies/secret-access-policies.view";
|
||||
import { SecretView } from "../models/view/secret.view";
|
||||
import { AccessPolicyService } from "../shared/access-policies/access-policy.service";
|
||||
|
||||
import { SecretService } from "./secret.service";
|
||||
|
||||
describe("SecretService", () => {
|
||||
let sut: SecretService;
|
||||
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const accessPolicyService = mock<AccessPolicyService>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
sut = new SecretService(cryptoService, apiService, encryptService, accessPolicyService);
|
||||
|
||||
encryptService.encrypt.mockResolvedValue({
|
||||
encryptedString: "mockEncryptedString",
|
||||
} as EncString);
|
||||
encryptService.decryptToUtf8.mockResolvedValue(mockUnencryptedData);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(sut).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("emits the secret created", async () => {
|
||||
apiService.send.mockResolvedValue(mockedSecretResponse);
|
||||
|
||||
sut.secret$.subscribe((secret) => {
|
||||
expect(secret).toBeDefined();
|
||||
expect(secret).toEqual(expectedSecretView);
|
||||
});
|
||||
|
||||
await sut.create("organizationId", secretView, secretAccessPoliciesView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("emits the secret updated", async () => {
|
||||
apiService.send.mockResolvedValue(mockedSecretResponse);
|
||||
|
||||
sut.secret$.subscribe((secret) => {
|
||||
expect(secret).toBeDefined();
|
||||
expect(secret).toEqual(expectedSecretView);
|
||||
});
|
||||
|
||||
await sut.update("organizationId", secretView, secretAccessPoliciesView);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockedSecretResponse: any = {
|
||||
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
|
||||
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
|
||||
key: "mockEncryptedString",
|
||||
value: "mockEncryptedString",
|
||||
note: "mockEncryptedString",
|
||||
creationDate: "2024-07-12T15:45:17.49823Z",
|
||||
revisionDate: "2024-07-12T15:45:17.49823Z",
|
||||
projects: [
|
||||
{
|
||||
id: "502d93ae-a084-490a-8a64-b187015eb69c",
|
||||
name: "mockEncryptedString",
|
||||
},
|
||||
],
|
||||
read: true,
|
||||
write: true,
|
||||
object: "secret",
|
||||
};
|
||||
|
||||
const secretView: SecretView = {
|
||||
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
|
||||
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
|
||||
name: "key",
|
||||
value: "value",
|
||||
note: "note",
|
||||
creationDate: "2024-06-12T15:45:17.49823Z",
|
||||
revisionDate: "2024-06-12T15:45:17.49823Z",
|
||||
projects: [
|
||||
{
|
||||
id: "502d93ae-a084-490a-8a64-b187015eb69c",
|
||||
name: "project-name",
|
||||
},
|
||||
],
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
|
||||
const secretAccessPoliciesView: SecretAccessPoliciesView = {
|
||||
userAccessPolicies: [],
|
||||
groupAccessPolicies: [],
|
||||
serviceAccountAccessPolicies: [],
|
||||
};
|
||||
|
||||
const mockUnencryptedData = "mockUnEncryptedString";
|
||||
|
||||
const expectedSecretView: SecretView = {
|
||||
id: "001f835c-aa41-4f25-bfbf-b18d0103a1db",
|
||||
organizationId: "da0eea55-8604-4307-8a24-b187015e3071",
|
||||
name: mockUnencryptedData,
|
||||
value: mockUnencryptedData,
|
||||
note: mockUnencryptedData,
|
||||
creationDate: "2024-07-12T15:45:17.49823Z",
|
||||
revisionDate: "2024-07-12T15:45:17.49823Z",
|
||||
projects: [
|
||||
{
|
||||
id: "502d93ae-a084-490a-8a64-b187015eb69c",
|
||||
name: mockUnencryptedData,
|
||||
},
|
||||
],
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
@ -7,9 +7,11 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { SecretAccessPoliciesView } from "../models/view/access-policies/secret-access-policies.view";
|
||||
import { SecretListView } from "../models/view/secret-list.view";
|
||||
import { SecretProjectView } from "../models/view/secret-project.view";
|
||||
import { SecretView } from "../models/view/secret.view";
|
||||
import { AccessPolicyService } from "../shared/access-policies/access-policy.service";
|
||||
import { BulkOperationStatus } from "../shared/dialogs/bulk-status-dialog.component";
|
||||
|
||||
import { SecretRequest } from "./requests/secret.request";
|
||||
@ -30,6 +32,7 @@ export class SecretService {
|
||||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private encryptService: EncryptService,
|
||||
private accessPolicyService: AccessPolicyService,
|
||||
) {}
|
||||
|
||||
async getBySecretId(secretId: string): Promise<SecretView> {
|
||||
@ -65,8 +68,16 @@ export class SecretService {
|
||||
return await this.createSecretsListView(organizationId, results);
|
||||
}
|
||||
|
||||
async create(organizationId: string, secretView: SecretView) {
|
||||
const request = await this.getSecretRequest(organizationId, secretView);
|
||||
async create(
|
||||
organizationId: string,
|
||||
secretView: SecretView,
|
||||
secretAccessPoliciesView: SecretAccessPoliciesView,
|
||||
) {
|
||||
const request = await this.getSecretRequest(
|
||||
organizationId,
|
||||
secretView,
|
||||
secretAccessPoliciesView,
|
||||
);
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/" + organizationId + "/secrets",
|
||||
@ -77,8 +88,16 @@ export class SecretService {
|
||||
this._secret.next(await this.createSecretView(new SecretResponse(r)));
|
||||
}
|
||||
|
||||
async update(organizationId: string, secretView: SecretView) {
|
||||
const request = await this.getSecretRequest(organizationId, secretView);
|
||||
async update(
|
||||
organizationId: string,
|
||||
secretView: SecretView,
|
||||
secretAccessPoliciesView: SecretAccessPoliciesView,
|
||||
) {
|
||||
const request = await this.getSecretRequest(
|
||||
organizationId,
|
||||
secretView,
|
||||
secretAccessPoliciesView,
|
||||
);
|
||||
const r = await this.apiService.send("PUT", "/secrets/" + secretView.id, request, true, true);
|
||||
this._secret.next(await this.createSecretView(new SecretResponse(r)));
|
||||
}
|
||||
@ -140,6 +159,7 @@ export class SecretService {
|
||||
private async getSecretRequest(
|
||||
organizationId: string,
|
||||
secretView: SecretView,
|
||||
secretAccessPoliciesView: SecretAccessPoliciesView,
|
||||
): Promise<SecretRequest> {
|
||||
const orgKey = await this.getOrganizationKey(organizationId);
|
||||
const request = new SecretRequest();
|
||||
@ -155,6 +175,9 @@ export class SecretService {
|
||||
|
||||
secretView.projects?.forEach((e) => request.projectIds.push(e.id));
|
||||
|
||||
request.accessPoliciesRequests =
|
||||
this.accessPolicyService.getSecretAccessPoliciesRequest(secretAccessPoliciesView);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
|
@ -2,9 +2,9 @@
|
||||
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<div class="tw-my-4 tw-max-w-xl">
|
||||
<app-callout type="info" title="{{ 'exportingOrganizationSecretDataTitle' | i18n }}">
|
||||
<bit-callout type="info" title="{{ 'exportingOrganizationSecretDataTitle' | i18n }}">
|
||||
{{ "exportingOrganizationSecretDataDescription" | i18n: orgName }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
</div>
|
||||
|
||||
<bit-form-field class="tw-max-w-sm">
|
||||
|
@ -64,10 +64,12 @@ describe("AccessPolicySelectorService", () => {
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
selectedPolicyValues.push(
|
||||
createApItemValueType({
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -80,15 +82,17 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when current user isn't owner/admin and a group Read policy is submitted that the user is a member of", async () => {
|
||||
@ -96,12 +100,15 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUserInGroup: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -114,12 +121,15 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
currentUserInGroup: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -132,12 +142,15 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
currentUserInGroup: false,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -150,16 +163,21 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
currentUserInGroup: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -172,16 +190,21 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
currentUserInGroup: false,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -194,16 +217,21 @@ describe("AccessPolicySelectorService", () => {
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType({
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUserInGroup: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "groupId",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showAccessRemovalWarning(org.id, selectedPolicyValues);
|
||||
@ -211,6 +239,246 @@ describe("AccessPolicySelectorService", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
describe("showSecretAccessRemovalWarning", () => {
|
||||
it("returns false when there are no current access policies", async () => {
|
||||
const org = orgFactory();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns false when current user is admin", async () => {
|
||||
const org = orgFactory();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns false when current user is owner", async () => {
|
||||
const org = orgFactory();
|
||||
org.type = OrganizationUserType.Owner;
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns false when current non-admin user doesn't have Read, Write access with current access policies -- user policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns false when current non-admin user doesn't have Read, Write access with current access policies -- group policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns true when current non-admin user has Read, Write access with current access policies and doesn't with selected -- user policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
type: ApItemEnum.User,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("returns true when current non-admin user has Read, Write access with current access policies and doesn't with selected -- group policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType(
|
||||
{
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("returns false when current non-admin user has Read, Write access with current access policies and does with selected -- user policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType(
|
||||
{
|
||||
type: ApItemEnum.User,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
it("returns false when current non-admin user has Read, Write access with current access policies and does with selected -- group policy", async () => {
|
||||
const org = setupUserOrg();
|
||||
organizationService.get.calledWith(org.id).mockResolvedValue(org);
|
||||
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType(
|
||||
{
|
||||
type: ApItemEnum.Group,
|
||||
permission: ApPermissionEnum.CanReadWrite,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = await sut.showSecretAccessRemovalWarning(
|
||||
org.id,
|
||||
currentAccessPolicies,
|
||||
selectedPolicyValues,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("isAccessRemoval", () => {
|
||||
it("returns false when there are no previous policies and no selected policies", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [];
|
||||
@ -223,11 +491,13 @@ describe("AccessPolicySelectorService", () => {
|
||||
it("returns false when there are no previous policies", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
|
||||
@ -236,18 +506,22 @@ describe("AccessPolicySelectorService", () => {
|
||||
});
|
||||
it("returns false when previous policies and selected policies are the same", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
|
||||
@ -256,23 +530,29 @@ describe("AccessPolicySelectorService", () => {
|
||||
});
|
||||
it("returns false when previous policies are still selected", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType({
|
||||
id: "example-2",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "example-2",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
|
||||
@ -281,23 +561,29 @@ describe("AccessPolicySelectorService", () => {
|
||||
});
|
||||
it("returns true when previous policies are not selected", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType({
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemViewType(
|
||||
{
|
||||
id: "example",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [
|
||||
createApItemValueType({
|
||||
id: "test",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType({
|
||||
id: "example-2",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "test",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
createApItemValueType(
|
||||
{
|
||||
id: "example-2",
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
const result = sut.isAccessRemoval(currentAccessPolicies, selectedPolicyValues);
|
||||
@ -306,10 +592,12 @@ describe("AccessPolicySelectorService", () => {
|
||||
});
|
||||
it("returns true when there are previous policies and nothing was selected", async () => {
|
||||
const currentAccessPolicies: ApItemViewType[] = [
|
||||
createApItemViewType({
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
currentUser: true,
|
||||
}),
|
||||
createApItemViewType(
|
||||
{
|
||||
permission: ApPermissionEnum.CanRead,
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
const selectedPolicyValues: ApItemValueType[] = [];
|
||||
|
||||
@ -331,17 +619,31 @@ const orgFactory = (props: Partial<Organization> = {}) =>
|
||||
props,
|
||||
);
|
||||
|
||||
function createApItemValueType(options: Partial<ApItemValueType> = {}) {
|
||||
return {
|
||||
function createApItemValueType(
|
||||
options: Partial<ApItemValueType> = {},
|
||||
currentUser = false,
|
||||
currentUserInGroup = false,
|
||||
) {
|
||||
const item: ApItemValueType = {
|
||||
id: options?.id ?? "test",
|
||||
type: options?.type ?? ApItemEnum.User,
|
||||
permission: options?.permission ?? ApPermissionEnum.CanRead,
|
||||
currentUserInGroup: options?.currentUserInGroup ?? false,
|
||||
};
|
||||
if (item.type === ApItemEnum.User) {
|
||||
item.currentUser = currentUser;
|
||||
}
|
||||
if (item.type === ApItemEnum.Group) {
|
||||
item.currentUserInGroup = currentUserInGroup;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function createApItemViewType(options: Partial<ApItemViewType> = {}) {
|
||||
return {
|
||||
function createApItemViewType(
|
||||
options: Partial<ApItemViewType> = {},
|
||||
currentUser = false,
|
||||
currentUserInGroup = false,
|
||||
) {
|
||||
const item: ApItemViewType = {
|
||||
id: options?.id ?? "test",
|
||||
listName: options?.listName ?? "test",
|
||||
labelName: options?.labelName ?? "test",
|
||||
@ -349,6 +651,13 @@ function createApItemViewType(options: Partial<ApItemViewType> = {}) {
|
||||
permission: options?.permission ?? ApPermissionEnum.CanRead,
|
||||
readOnly: options?.readOnly ?? false,
|
||||
};
|
||||
if (item.type === ApItemEnum.User) {
|
||||
item.currentUser = currentUser;
|
||||
}
|
||||
if (item.type === ApItemEnum.Group) {
|
||||
item.currentUserInGroup = currentUserInGroup;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function setupUserOrg() {
|
||||
|
@ -22,26 +22,29 @@ export class AccessPolicySelectorService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedUserReadWritePolicy = selectedPoliciesValues.find(
|
||||
(s) =>
|
||||
s.type === ApItemEnum.User &&
|
||||
s.currentUser &&
|
||||
s.permission === ApPermissionEnum.CanReadWrite,
|
||||
);
|
||||
if (!this.userHasReadWriteAccess(selectedPoliciesValues)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const selectedGroupReadWritePolicies = selectedPoliciesValues.filter(
|
||||
(s) =>
|
||||
s.type === ApItemEnum.Group &&
|
||||
s.permission == ApPermissionEnum.CanReadWrite &&
|
||||
s.currentUserInGroup,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedGroupReadWritePolicies == null || selectedGroupReadWritePolicies.length == 0) {
|
||||
if (selectedUserReadWritePolicy == null) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
async showSecretAccessRemovalWarning(
|
||||
organizationId: string,
|
||||
current: ApItemViewType[],
|
||||
selectedPoliciesValues: ApItemValueType[],
|
||||
): Promise<boolean> {
|
||||
if (current.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const organization = await this.organizationService.get(organizationId);
|
||||
if (organization.isOwner || organization.isAdmin || !this.userHasReadWriteAccess(current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.userHasReadWriteAccess(selectedPoliciesValues)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -67,4 +70,25 @@ export class AccessPolicySelectorService {
|
||||
const selectedIds = selected.map((x) => x.id);
|
||||
return !currentIds.every((id) => selectedIds.includes(id));
|
||||
}
|
||||
|
||||
private userHasReadWriteAccess(policies: ApItemValueType[] | ApItemViewType[]): boolean {
|
||||
const userReadWritePolicy = (policies as Array<ApItemValueType | ApItemViewType>).find(
|
||||
(s) =>
|
||||
s.type === ApItemEnum.User &&
|
||||
s.currentUser &&
|
||||
s.permission === ApPermissionEnum.CanReadWrite,
|
||||
);
|
||||
|
||||
const groupReadWritePolicies = (policies as Array<ApItemValueType | ApItemViewType>).filter(
|
||||
(s) =>
|
||||
s.type === ApItemEnum.Group &&
|
||||
s.permission === ApPermissionEnum.CanReadWrite &&
|
||||
s.currentUserInGroup,
|
||||
);
|
||||
|
||||
if (groupReadWritePolicies.length > 0 || userReadWritePolicy !== undefined) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import { ServiceAccountGrantedPoliciesRequest } from "../access-policies/models/
|
||||
|
||||
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
|
||||
import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request";
|
||||
import { SecretAccessPoliciesRequest } from "./models/requests/secret-access-policies.request";
|
||||
import {
|
||||
GroupAccessPolicyResponse,
|
||||
UserAccessPolicyResponse,
|
||||
@ -233,6 +234,20 @@ export class AccessPolicyService {
|
||||
return await this.createPotentialGranteeViews(organizationId, results.data);
|
||||
}
|
||||
|
||||
getSecretAccessPoliciesRequest(view: SecretAccessPoliciesView): SecretAccessPoliciesRequest {
|
||||
return {
|
||||
userAccessPolicyRequests: view.userAccessPolicies.map((ap) => {
|
||||
return this.getAccessPolicyRequest(ap.organizationUserId, ap);
|
||||
}),
|
||||
groupAccessPolicyRequests: view.groupAccessPolicies.map((ap) => {
|
||||
return this.getAccessPolicyRequest(ap.groupId, ap);
|
||||
}),
|
||||
serviceAccountAccessPolicyRequests: view.serviceAccountAccessPolicies.map((ap) => {
|
||||
return this.getAccessPolicyRequest(ap.serviceAccountId, ap);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
|
||||
return await this.cryptoService.getOrgKey(organizationId);
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
import { AccessPolicyRequest } from "./access-policy.request";
|
||||
|
||||
export class SecretAccessPoliciesRequest {
|
||||
userAccessPolicyRequests: AccessPolicyRequest[];
|
||||
groupAccessPolicyRequests: AccessPolicyRequest[];
|
||||
serviceAccountAccessPolicyRequests: AccessPolicyRequest[];
|
||||
}
|
@ -14,7 +14,9 @@
|
||||
"@bitwarden/components": ["../../libs/components/src"],
|
||||
"@bitwarden/generator-components": ["../../libs/tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../../libs/tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../../libs/tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../../libs/tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../../libs/tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": [
|
||||
"../../libs/tools/export/vault-export/vault-export-core/src"
|
||||
],
|
||||
|
@ -32,6 +32,9 @@ module.exports = {
|
||||
"<rootDir>/libs/components/jest.config.js",
|
||||
"<rootDir>/libs/tools/export/vault-export/vault-export-core/jest.config.js",
|
||||
"<rootDir>/libs/tools/generator/core/jest.config.js",
|
||||
"<rootDir>/libs/tools/generator/extensions/history/jest.config.js",
|
||||
"<rootDir>/libs/tools/generator/extensions/legacy/jest.config.js",
|
||||
"<rootDir>/libs/tools/generator/extensions/navigation/jest.config.js",
|
||||
"<rootDir>/libs/importer/jest.config.js",
|
||||
"<rootDir>/libs/platform/jest.config.js",
|
||||
"<rootDir>/libs/node/jest.config.js",
|
||||
|
@ -41,7 +41,7 @@ export class RemovePasswordComponent implements OnInit {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async convert() {
|
||||
convert = async () => {
|
||||
this.continuing = true;
|
||||
this.actionPromise = this.keyConnectorService.migrateUser();
|
||||
|
||||
@ -59,9 +59,9 @@ export class RemovePasswordComponent implements OnInit {
|
||||
} catch (e) {
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async leave() {
|
||||
leave = async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: this.organization.name,
|
||||
content: { key: "leaveOrganizationConfirmation" },
|
||||
@ -84,5 +84,5 @@ export class RemovePasswordComponent implements OnInit {
|
||||
} catch (e) {
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -3,3 +3,4 @@ export * from "./login-email.service";
|
||||
export * from "./login-strategy.service";
|
||||
export * from "./user-decryption-options.service.abstraction";
|
||||
export * from "./auth-request.service.abstraction";
|
||||
export * from "./user-key-rotation-data-provider.abstraction";
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
/**
|
||||
* Constructs key rotation requests for data encryption by the user key.
|
||||
* @typeparam TRequest A request model that contains re-encrypted data, must have an id property
|
||||
*/
|
||||
export interface UserKeyRotationDataProvider<
|
||||
TRequest extends { id: string } | { organizationId: string },
|
||||
> {
|
||||
/**
|
||||
* Provides re-encrypted data for the user key rotation process
|
||||
* @param originalUserKey The original user key, useful for decrypting data
|
||||
* @param newUserKey The new user key to use for re-encryption
|
||||
* @param userId The owner of the data, useful for fetching data
|
||||
* @returns A list of data that has been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<TRequest[]>;
|
||||
}
|
@ -269,6 +269,15 @@ export abstract class CryptoService {
|
||||
*/
|
||||
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey>;
|
||||
|
||||
/**
|
||||
* Gets an observable stream of the given users decrypted private key with legacy support,
|
||||
* will emit null if the user doesn't have a UserKey to decrypt the encrypted private key
|
||||
* or null if the user doesn't have an encrypted private key at all.
|
||||
*
|
||||
* @param userId The user id of the user to get the data for.
|
||||
*/
|
||||
abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey>;
|
||||
|
||||
/**
|
||||
* Generates a fingerprint phrase for the user based on their public key
|
||||
* @param fingerprintMaterial Fingerprint material
|
||||
|
@ -929,6 +929,10 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey));
|
||||
}
|
||||
|
||||
userPrivateKeyWithLegacySupport$(userId: UserId): Observable<UserPrivateKey> {
|
||||
return this.userPrivateKeyHelper$(userId, true).pipe(map((keys) => keys?.userPrivateKey));
|
||||
}
|
||||
|
||||
private userPrivateKeyHelper$(userId: UserId, legacySupport: boolean) {
|
||||
const userKey$ = legacySupport ? this.userKeyWithLegacySupport$(userId) : this.userKey$(userId);
|
||||
return userKey$.pipe(
|
||||
@ -1010,7 +1014,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
orgKeys$(userId: UserId) {
|
||||
return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys));
|
||||
return this.cipherDecryptionKeys$(userId, true).pipe(map((keys) => keys?.orgKeys));
|
||||
}
|
||||
|
||||
cipherDecryptionKeys$(
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
|
||||
export abstract class SendService {
|
||||
export abstract class SendService implements UserKeyRotationDataProvider<SendWithIdRequest> {
|
||||
sends$: Observable<Send[]>;
|
||||
sendViews$: Observable<SendView[]>;
|
||||
|
||||
@ -31,7 +34,11 @@ export abstract class SendService {
|
||||
* @throws Error if the new user key is null or undefined
|
||||
* @returns A list of user sends that have been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedKeys: (newUserKey: UserKey) => Promise<SendWithIdRequest[]>;
|
||||
getRotatedData: (
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<SendWithIdRequest[]>;
|
||||
/**
|
||||
* @deprecated Do not call this, use the sends$ observable collection
|
||||
*/
|
||||
|
@ -400,8 +400,11 @@ describe("SendService", () => {
|
||||
expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send"));
|
||||
});
|
||||
|
||||
describe("getRotatedKeys", () => {
|
||||
describe("getRotatedData", () => {
|
||||
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Send Key");
|
||||
@ -409,27 +412,30 @@ describe("SendService", () => {
|
||||
});
|
||||
|
||||
it("returns re-encrypted user sends", async () => {
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const result = await sendService.getRotatedKeys(newUserKey);
|
||||
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]);
|
||||
});
|
||||
|
||||
it("returns null if there are no sends", async () => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sendService.replace(null);
|
||||
it("returns empty array if there are no sends", async () => {
|
||||
await sendService.replace(null);
|
||||
|
||||
await awaitAsync();
|
||||
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const result = await sendService.getRotatedKeys(newUserKey);
|
||||
const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws if the original user key is null", async () => {
|
||||
await expect(sendService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||
"Original user key is required for rotation.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(sendService.getRotatedKeys(null)).rejects.toThrowError(
|
||||
await expect(sendService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required for rotation.",
|
||||
);
|
||||
});
|
||||
|
@ -9,6 +9,7 @@ import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
@ -258,12 +259,17 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
await this.stateProvider.setEncryptedSends(sends);
|
||||
}
|
||||
|
||||
async getRotatedKeys(newUserKey: UserKey): Promise<SendWithIdRequest[]> {
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<SendWithIdRequest[]> {
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required for rotation.");
|
||||
}
|
||||
|
||||
const originalUserKey = await this.cryptoService.getUserKey();
|
||||
if (originalUserKey == null) {
|
||||
throw new Error("Original user key is required for rotation.");
|
||||
}
|
||||
|
||||
const req = await firstValueFrom(
|
||||
this.sends$.pipe(
|
||||
|
@ -1,19 +1,22 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
import { LocalData } from "@bitwarden/common/vault/models/data/local.data";
|
||||
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { Field } from "../models/domain/field";
|
||||
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { FieldView } from "../models/view/field.view";
|
||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
|
||||
export abstract class CipherService {
|
||||
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
|
||||
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
||||
ciphers$: Observable<Record<CipherId, CipherData>>;
|
||||
localData$: Observable<Record<CipherId, LocalData>>;
|
||||
@ -146,4 +149,17 @@ export abstract class CipherService {
|
||||
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
|
||||
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
|
||||
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
|
||||
/**
|
||||
* Returns user ciphers re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if new user key is null
|
||||
* @returns a list of user ciphers that have been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData: (
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<CipherWithIdRequest[]>;
|
||||
}
|
||||
|
@ -1,11 +1,16 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { FolderData } from "../../models/data/folder.data";
|
||||
import { Folder } from "../../models/domain/folder";
|
||||
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
|
||||
import { FolderView } from "../../models/view/folder.view";
|
||||
|
||||
export abstract class FolderService {
|
||||
export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> {
|
||||
folders$: Observable<Folder[]>;
|
||||
folderViews$: Observable<FolderView[]>;
|
||||
|
||||
@ -22,6 +27,19 @@ export abstract class FolderService {
|
||||
*/
|
||||
getAllDecryptedFromState: () => Promise<FolderView[]>;
|
||||
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
|
||||
/**
|
||||
* Returns user folders re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if new user key is null
|
||||
* @returns a list of user folders that have been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData: (
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<FolderWithIdRequest[]>;
|
||||
}
|
||||
|
||||
export abstract class InternalFolderService extends FolderService {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
@ -10,7 +10,7 @@ import { AutofillSettingsService } from "../../autofill/services/autofill-settin
|
||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { CipherDecryptionKeys, CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
@ -19,8 +19,8 @@ import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../platform/services/container.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey } from "../../types/key";
|
||||
import { CipherId, UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
||||
@ -331,6 +331,64 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
setEncryptionKeyFlag(true);
|
||||
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
||||
|
||||
searchService.indexedEntityId$ = of(null);
|
||||
stateService.getUserId.mockResolvedValue(mockUserId);
|
||||
|
||||
const keys = {
|
||||
userKey: originalUserKey,
|
||||
} as CipherDecryptionKeys;
|
||||
cryptoService.cipherDecryptionKeys$.mockReturnValue(of(keys));
|
||||
|
||||
const cipher1 = new CipherView(cipherObj);
|
||||
cipher1.id = "Cipher 1";
|
||||
const cipher2 = new CipherView(cipherObj);
|
||||
cipher2.id = "Cipher 2";
|
||||
|
||||
decryptedCiphers = new BehaviorSubject({
|
||||
Cipher1: cipher1,
|
||||
Cipher2: cipher2,
|
||||
});
|
||||
cipherService.cipherViews$ = decryptedCiphers;
|
||||
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Cipher Key");
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
|
||||
cryptoService.makeCipherKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user ciphers", async () => {
|
||||
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
|
||||
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
|
||||
});
|
||||
|
||||
it("throws if the original user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||
"Original user key is required to rotate ciphers",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required to rotate ciphers",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setEncryptionKeyFlag(value: boolean) {
|
||||
|
@ -56,6 +56,7 @@ import { CipherCollectionsRequest } from "../models/request/cipher-collections.r
|
||||
import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
||||
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
||||
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
||||
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||
import { CipherRequest } from "../models/request/cipher.request";
|
||||
import { CipherResponse } from "../models/response/cipher.response";
|
||||
import { AttachmentView } from "../models/view/attachment.view";
|
||||
@ -1168,6 +1169,34 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<CipherWithIdRequest[]> {
|
||||
if (originalUserKey == null) {
|
||||
throw new Error("Original user key is required to rotate ciphers");
|
||||
}
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required to rotate ciphers");
|
||||
}
|
||||
|
||||
let encryptedCiphers: CipherWithIdRequest[] = [];
|
||||
|
||||
const ciphers = await this.getAllDecrypted();
|
||||
if (!ciphers || ciphers.length === 0) {
|
||||
return encryptedCiphers;
|
||||
}
|
||||
encryptedCiphers = await Promise.all(
|
||||
ciphers.map(async (cipher) => {
|
||||
const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey);
|
||||
return new CipherWithIdRequest(encryptedCipher);
|
||||
}),
|
||||
);
|
||||
|
||||
return encryptedCiphers;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
// In the case of a cipher that is being shared with an organization, we want to decrypt the
|
||||
|
@ -178,6 +178,29 @@ describe("Folder Service", () => {
|
||||
// });
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptedKey = new EncString("Re-encrypted Folder");
|
||||
cryptoService.encrypt.mockResolvedValue(encryptedKey);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user folders", async () => {
|
||||
const result = await folderService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result[0]).toMatchObject({ id: "1", name: "Re-encrypted Folder" });
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(folderService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required for rotation.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function folderData(id: string, name: string) {
|
||||
const data = new FolderData({} as any);
|
||||
data.id = id;
|
||||
|
@ -6,12 +6,14 @@ import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { FolderData } from "../../../vault/models/data/folder.data";
|
||||
import { Folder } from "../../../vault/models/domain/folder";
|
||||
import { FolderView } from "../../../vault/models/view/folder.view";
|
||||
import { Cipher } from "../../models/domain/cipher";
|
||||
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
|
||||
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
|
||||
|
||||
export class FolderService implements InternalFolderServiceAbstraction {
|
||||
@ -170,4 +172,27 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
decryptedFolders.push(noneFolder);
|
||||
return decryptedFolders;
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<FolderWithIdRequest[]> {
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required for rotation.");
|
||||
}
|
||||
|
||||
let encryptedFolders: FolderWithIdRequest[] = [];
|
||||
const folders = await firstValueFrom(this.folderViews$);
|
||||
if (!folders) {
|
||||
return encryptedFolders;
|
||||
}
|
||||
encryptedFolders = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
const encryptedFolder = await this.encrypt(folder, newUserKey);
|
||||
return new FolderWithIdRequest(encryptedFolder);
|
||||
}),
|
||||
);
|
||||
return encryptedFolders;
|
||||
}
|
||||
}
|
||||
|
@ -10,12 +10,14 @@ export type Sort = {
|
||||
fn?: SortFn;
|
||||
};
|
||||
|
||||
export type FilterFn<T> = (data: T) => boolean;
|
||||
|
||||
// Loosely based on CDK TableDataSource
|
||||
// https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
export class TableDataSource<T> extends DataSource<T> {
|
||||
private readonly _data: BehaviorSubject<T[]>;
|
||||
private readonly _sort: BehaviorSubject<Sort>;
|
||||
private readonly _filter = new BehaviorSubject<string>("");
|
||||
private readonly _filter = new BehaviorSubject<string | FilterFn<T>>(null);
|
||||
private readonly _renderData = new BehaviorSubject<T[]>([]);
|
||||
private _renderChangesSubscription: Subscription | null = null;
|
||||
|
||||
@ -55,11 +57,15 @@ export class TableDataSource<T> extends DataSource<T> {
|
||||
return this._sort.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to apply to the `data`.
|
||||
*
|
||||
* If a string is provided, it will be converted to a filter using {@link simpleStringFilter}
|
||||
**/
|
||||
get filter() {
|
||||
return this._filter.value;
|
||||
}
|
||||
|
||||
set filter(filter: string) {
|
||||
set filter(filter: string | FilterFn<T>) {
|
||||
this._filter.next(filter);
|
||||
// Normally the `filteredData` is updated by the re-render
|
||||
// subscription, but that won't happen if it's inactive.
|
||||
@ -95,10 +101,11 @@ export class TableDataSource<T> extends DataSource<T> {
|
||||
}
|
||||
|
||||
private filterData(data: T[]): T[] {
|
||||
this.filteredData =
|
||||
this.filter == null || this.filter === ""
|
||||
? data
|
||||
: data.filter((obj) => this.filterPredicate(obj, this.filter));
|
||||
const filter =
|
||||
typeof this.filter === "string"
|
||||
? TableDataSource.simpleStringFilter(this.filter)
|
||||
: this.filter;
|
||||
this.filteredData = this.filter == null ? data : data.filter((obj) => filter(obj));
|
||||
|
||||
return this.filteredData;
|
||||
}
|
||||
@ -207,36 +214,39 @@ export class TableDataSource<T> extends DataSource<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
* Modified from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
|
||||
* License: MIT
|
||||
* Copyright (c) 2022 Google LLC.
|
||||
*
|
||||
* Checks if a data object matches the data source's filter string. By default, each data object
|
||||
* @param filter the string to search for
|
||||
* @returns a function that checks if a data object matches the provided `filter` string. Each data object
|
||||
* is converted to a string of its properties and returns true if the filter has
|
||||
* at least one occurrence in that string. By default, the filter string has its whitespace
|
||||
* trimmed and the match is case-insensitive. May be overridden for a custom implementation of
|
||||
* filter matching.
|
||||
* @param data Data object used to check against the filter.
|
||||
* @param filter Filter string that has been set on the data source.
|
||||
* @returns Whether the filter matches against the data
|
||||
* at least one occurrence in that string. The filter string has its whitespace
|
||||
* trimmed and the match is case-insensitive.
|
||||
*/
|
||||
protected filterPredicate(data: T, filter: string): boolean {
|
||||
// Transform the data into a lowercase string of all property values.
|
||||
const dataStr = Object.keys(data as unknown as Record<string, any>)
|
||||
.reduce((currentTerm: string, key: string) => {
|
||||
// Use an obscure Unicode character to delimit the words in the concatenated string.
|
||||
// This avoids matches where the values of two columns combined will match the user's query
|
||||
// (e.g. `Flute` and `Stop` will match `Test`). The character is intended to be something
|
||||
// that has a very low chance of being typed in by somebody in a text field. This one in
|
||||
// particular is "White up-pointing triangle with dot" from
|
||||
// https://en.wikipedia.org/wiki/List_of_Unicode_characters
|
||||
return currentTerm + (data as unknown as Record<string, any>)[key] + "◬";
|
||||
}, "")
|
||||
.toLowerCase();
|
||||
static readonly simpleStringFilter = <T>(filter: string): FilterFn<T> => {
|
||||
return (data: T): boolean => {
|
||||
if (!filter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Transform the filter by converting it to lowercase and removing whitespace.
|
||||
const transformedFilter = filter.trim().toLowerCase();
|
||||
// Transform the data into a lowercase string of all property values.
|
||||
const dataStr = Object.keys(data as unknown as Record<string, any>)
|
||||
.reduce((currentTerm: string, key: string) => {
|
||||
// Use an obscure Unicode character to delimit the words in the concatenated string.
|
||||
// This avoids matches where the values of two columns combined will match the user's query
|
||||
// (e.g. `Flute` and `Stop` will match `Test`). The character is intended to be something
|
||||
// that has a very low chance of being typed in by somebody in a text field. This one in
|
||||
// particular is "White up-pointing triangle with dot" from
|
||||
// https://en.wikipedia.org/wiki/List_of_Unicode_characters
|
||||
return currentTerm + (data as unknown as Record<string, any>)[key] + "◬";
|
||||
}, "")
|
||||
.toLowerCase();
|
||||
|
||||
return dataStr.indexOf(transformedFilter) != -1;
|
||||
}
|
||||
// Transform the filter by converting it to lowercase and removing whitespace.
|
||||
const transformedFilter = filter.trim().toLowerCase();
|
||||
|
||||
return dataStr.indexOf(transformedFilter) != -1;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -121,11 +121,20 @@ const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
|
||||
|
||||
### Filtering
|
||||
|
||||
The `TableDataSource` supports a rudimentary filtering capability most commonly used to implement a
|
||||
search function. It works by converting each entry into a string of it's properties. The string is
|
||||
then compared against the filter value using a simple `indexOf`check.
|
||||
Filtering is supported by passing a filter predicate to `filter`.
|
||||
|
||||
```ts
|
||||
dataSource.filter = (data) => data.orgType === "family";
|
||||
```
|
||||
|
||||
Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`.
|
||||
It works by converting each entry into a string of it's properties. The provided string is then
|
||||
compared against the filter value using a simple `indexOf` check. For convienence, you can also just
|
||||
pass a string directly.
|
||||
|
||||
```ts
|
||||
dataSource.filter = TableDataSource.simpleStringFilter("search value");
|
||||
// or
|
||||
dataSource.filter = "search value";
|
||||
```
|
||||
|
||||
|
@ -12,7 +12,9 @@
|
||||
"@bitwarden/components": ["../components/src"],
|
||||
"@bitwarden/generator-components": ["../tools/generator/components/src"],
|
||||
"@bitwarden/generator-core": ["../tools/generator/core/src"],
|
||||
"@bitwarden/generator-extensions": ["../tools/generator/extensions/src"],
|
||||
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
|
||||
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
|
||||
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
|
||||
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
|
||||
"@bitwarden/vault-export-ui": ["../tools/export/vault-export/vault-export-ui/src"],
|
||||
"@bitwarden/importer/core": ["../importer/src"],
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/generator-components",
|
||||
"version": "0.0.0",
|
||||
"description": "Angular components for the Bitwarden generators",
|
||||
"description": "Angular components for the Bitwarden credential generators",
|
||||
"keywords": [
|
||||
"bitwarden"
|
||||
],
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/generator-core",
|
||||
"version": "0.0.0",
|
||||
"description": "TODO",
|
||||
"description": "Common Bitwarden credential generation logic",
|
||||
"keywords": [
|
||||
"bitwarden"
|
||||
],
|
||||
|
@ -1,5 +1,3 @@
|
||||
export { GeneratorHistoryService } from "../../../extensions/src/history/generator-history.abstraction";
|
||||
export { GeneratorNavigationService } from "../../../extensions/src/navigation/generator-navigation.service.abstraction";
|
||||
export { GeneratorService } from "./generator.service.abstraction";
|
||||
export { GeneratorStrategy } from "./generator-strategy.abstraction";
|
||||
export { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
|
@ -1,13 +1,13 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../../../shared/tsconfig.libs");
|
||||
const { compilerOptions } = require("../../../../shared/tsconfig.libs");
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
testMatch: ["**/+(*.)+(spec).+(ts)"],
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "../../../shared/test.environment.ts",
|
||||
testEnvironment: "../../../../shared/test.environment.ts",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../",
|
||||
prefix: "<rootDir>/../../../",
|
||||
}),
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/generator-extensions",
|
||||
"name": "@bitwarden/generator-history",
|
||||
"version": "0.0.0",
|
||||
"description": "TODO",
|
||||
"description": "Bitwarden credential generator history service",
|
||||
"keywords": [
|
||||
"bitwarden"
|
||||
],
|
@ -1,4 +1,4 @@
|
||||
import { GeneratorCategory, GeneratedCredential } from "./";
|
||||
import { GeneratorCategory, GeneratedCredential } from ".";
|
||||
|
||||
describe("GeneratedCredential", () => {
|
||||
describe("constructor", () => {
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user