mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM-22717] Expose DefaultUserCollectionEmail to clients (#15643)
* enforce restrictions based on collection type, set default collection type * fix ts strict errors * fix default collection enforcement in vault header * enforce default collection restrictions in vault collection row * enforce default collection restrictions in AC vault header * enforce default collection restriction for select all * fix ts strict error * switch to signal, fix feature flag * fix story * clean up * remove feature flag, move check for defaultCollecion to CollecitonView * fix test * remove unused configService * fix test: coerce null to undefined for collection Id * clean up leaky abstraction for default collection * fix ts-strict error * fix parens * add new property to models, update logic, refactor for ts-strict * fix type * rename defaultCollection getter * clean up * clean up * clean up, add comment, fix submit * add comment * add feature flag * check model for name * cleanup readonly logic, remove featureflag logic * wip * refactor CollectionRequest into Create and Update models * fix readonly logic * cleanup * set defaultUserCollectionEmail in decryption from Collection * split save into update/create methods * fix readonly logic * fix collections post and put requests * add defaultUserCollection email to model when submitting collection dialog
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { CollectionRequest } from "@bitwarden/admin-console/common";
|
import { UpdateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
@@ -225,7 +225,7 @@ export class EditCommand {
|
|||||||
: req.users.map(
|
: req.users.map(
|
||||||
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
|
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
|
||||||
);
|
);
|
||||||
const request = new CollectionRequest({
|
const request = new UpdateCollectionRequest({
|
||||||
name: await this.encryptService.encryptString(req.name, orgKey),
|
name: await this.encryptService.encryptString(req.name, orgKey),
|
||||||
externalId: req.externalId,
|
externalId: req.externalId,
|
||||||
users,
|
users,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as path from "path";
|
|||||||
|
|
||||||
import { firstValueFrom, map } from "rxjs";
|
import { firstValueFrom, map } from "rxjs";
|
||||||
|
|
||||||
import { CollectionRequest } from "@bitwarden/admin-console/common";
|
import { CreateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||||
@@ -233,7 +233,7 @@ export class CreateCommand {
|
|||||||
: req.users.map(
|
: req.users.map(
|
||||||
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
|
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
|
||||||
);
|
);
|
||||||
const request = new CollectionRequest({
|
const request = new CreateCollectionRequest({
|
||||||
name: await this.encryptService.encryptString(req.name, orgKey),
|
name: await this.encryptService.encryptString(req.name, orgKey),
|
||||||
externalId: req.externalId,
|
externalId: req.externalId,
|
||||||
groups,
|
groups,
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ export function cloneCollection(
|
|||||||
let cloned;
|
let cloned;
|
||||||
|
|
||||||
if (collection instanceof CollectionAdminView) {
|
if (collection instanceof CollectionAdminView) {
|
||||||
cloned = Object.assign(new CollectionAdminView({ ...collection }), collection);
|
cloned = Object.assign(
|
||||||
|
new CollectionAdminView({ ...collection, name: collection.name }),
|
||||||
|
collection,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
cloned = Object.assign(new CollectionView({ ...collection }), collection);
|
cloned = Object.assign(
|
||||||
|
new CollectionView({ ...collection, name: collection.name }),
|
||||||
|
collection,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.editMode &&
|
||||||
|
!this.collection.canEditName(this.organization) &&
|
||||||
|
this.formGroup.controls.name.dirty
|
||||||
|
) {
|
||||||
|
throw new Error("Cannot change readonly field: Name");
|
||||||
|
}
|
||||||
|
|
||||||
const parent = this.formGroup.controls.parent?.value;
|
const parent = this.formGroup.controls.parent?.value;
|
||||||
const collectionView = new CollectionAdminView({
|
const collectionView = new CollectionAdminView({
|
||||||
@@ -414,9 +421,13 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
collectionView.users = this.formGroup.controls.access.value
|
collectionView.users = this.formGroup.controls.access.value
|
||||||
.filter((v) => v.type === AccessItemType.Member)
|
.filter((v) => v.type === AccessItemType.Member)
|
||||||
.map(convertToSelectionView);
|
.map(convertToSelectionView);
|
||||||
|
collectionView.defaultUserCollectionEmail = this.collection.defaultUserCollectionEmail;
|
||||||
|
|
||||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||||
const savedCollection = await this.collectionAdminService.save(collectionView, userId);
|
|
||||||
|
const collectionResponse = this.editMode
|
||||||
|
? await this.collectionAdminService.update(collectionView, userId)
|
||||||
|
: await this.collectionAdminService.create(collectionView, userId);
|
||||||
|
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -426,7 +437,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.close(CollectionDialogAction.Saved, savedCollection);
|
this.close(CollectionDialogAction.Saved, collectionResponse);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected delete = async () => {
|
protected delete = async () => {
|
||||||
@@ -483,14 +494,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private handleFormGroupReadonly(readonly: boolean) {
|
private handleFormGroupReadonly(readonly: boolean) {
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
|
this.formGroup.controls.access.disable();
|
||||||
this.formGroup.controls.name.disable();
|
this.formGroup.controls.name.disable();
|
||||||
this.formGroup.controls.parent.disable();
|
this.formGroup.controls.parent.disable();
|
||||||
this.formGroup.controls.access.disable();
|
return;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
this.formGroup.controls.access.enable();
|
||||||
|
|
||||||
|
if (!this.editMode) {
|
||||||
this.formGroup.controls.name.enable();
|
this.formGroup.controls.name.enable();
|
||||||
this.formGroup.controls.parent.enable();
|
this.formGroup.controls.parent.enable();
|
||||||
this.formGroup.controls.access.enable();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canEditName = this.collection.canEditName(this.organization);
|
||||||
|
this.formGroup.controls.name[canEditName ? "enable" : "disable"]();
|
||||||
|
this.formGroup.controls.parent[canEditName ? "enable" : "disable"]();
|
||||||
}
|
}
|
||||||
|
|
||||||
private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) {
|
private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
|
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||||
import {
|
import {
|
||||||
InternalPolicyService,
|
InternalPolicyService,
|
||||||
@@ -317,7 +318,13 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: CollectionAdminService,
|
provide: CollectionAdminService,
|
||||||
useClass: DefaultCollectionAdminService,
|
useClass: DefaultCollectionAdminService,
|
||||||
deps: [ApiService, KeyServiceAbstraction, EncryptService, CollectionService],
|
deps: [
|
||||||
|
ApiService,
|
||||||
|
KeyServiceAbstraction,
|
||||||
|
EncryptService,
|
||||||
|
CollectionService,
|
||||||
|
OrganizationService,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: SdkLoadService,
|
provide: SdkLoadService,
|
||||||
|
|||||||
@@ -250,7 +250,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
collections.forEach((c) => {
|
collections.forEach((c) => {
|
||||||
const collectionCopy = cloneCollection(new CollectionView({ ...c })) as CollectionFilter;
|
const collectionCopy = cloneCollection(
|
||||||
|
new CollectionView({ ...c, name: c.name }),
|
||||||
|
) as CollectionFilter;
|
||||||
collectionCopy.icon = "bwi-collection-shared";
|
collectionCopy.icon = "bwi-collection-shared";
|
||||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export abstract class CollectionAdminService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
): Observable<CollectionAdminView[]>;
|
): Observable<CollectionAdminView[]>;
|
||||||
abstract save(
|
abstract update(
|
||||||
|
collection: CollectionAdminView,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<CollectionDetailsResponse>;
|
||||||
|
abstract create(
|
||||||
collection: CollectionAdminView,
|
collection: CollectionAdminView,
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
): Promise<CollectionDetailsResponse>;
|
): Promise<CollectionDetailsResponse>;
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ export class CollectionAdminView extends CollectionView {
|
|||||||
return this.id === Unassigned;
|
return this.id === Unassigned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
|
||||||
|
* that were DefaultUserCollections but where the relevant user has been offboarded.
|
||||||
|
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
|
||||||
|
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
|
||||||
|
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
|
||||||
|
* Do not edit or remove this unless you understand why.
|
||||||
|
*/
|
||||||
|
override canEditName(org: Organization): boolean {
|
||||||
|
return (this.canEdit(org) && !this.defaultUserCollectionEmail) || super.canEditName(org);
|
||||||
|
}
|
||||||
static async fromCollectionAccessDetails(
|
static async fromCollectionAccessDetails(
|
||||||
collection: CollectionAccessDetailsResponse,
|
collection: CollectionAccessDetailsResponse,
|
||||||
encryptService: EncryptService,
|
encryptService: EncryptService,
|
||||||
@@ -115,6 +126,7 @@ export class CollectionAdminView extends CollectionView {
|
|||||||
view.unmanaged = collection.unmanaged;
|
view.unmanaged = collection.unmanaged;
|
||||||
view.type = collection.type;
|
view.type = collection.type;
|
||||||
view.externalId = collection.externalId;
|
view.externalId = collection.externalId;
|
||||||
|
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||||
|
|
||||||
view.groups = collection.groups
|
view.groups = collection.groups
|
||||||
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
|
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
|
||||||
// @ts-strict-ignore
|
|
||||||
import { Collection } from "./collection";
|
import { Collection } from "./collection";
|
||||||
import { CollectionRequest } from "./collection.request";
|
import { BaseCollectionRequest } from "./collection.request";
|
||||||
|
|
||||||
export class CollectionWithIdRequest extends CollectionRequest {
|
export class CollectionWithIdRequest extends BaseCollectionRequest {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
|
||||||
constructor(collection?: Collection) {
|
constructor(collection: Collection) {
|
||||||
if (collection == null) {
|
if (collection == null || collection.name == null || collection.name.encryptedString == null) {
|
||||||
return;
|
throw new Error("CollectionWithIdRequest must contain name.");
|
||||||
}
|
}
|
||||||
super({
|
super({
|
||||||
name: collection.name,
|
|
||||||
externalId: collection.externalId,
|
externalId: collection.externalId,
|
||||||
});
|
});
|
||||||
|
this.name = collection.name.encryptedString;
|
||||||
this.id = collection.id;
|
this.id = collection.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export class CollectionData {
|
|||||||
id: CollectionId;
|
id: CollectionId;
|
||||||
organizationId: OrganizationId;
|
organizationId: OrganizationId;
|
||||||
name: string;
|
name: string;
|
||||||
|
defaultUserCollectionEmail: string | undefined;
|
||||||
externalId: string | undefined;
|
externalId: string | undefined;
|
||||||
readOnly: boolean = false;
|
readOnly: boolean = false;
|
||||||
manage: boolean = false;
|
manage: boolean = false;
|
||||||
@@ -24,6 +25,7 @@ export class CollectionData {
|
|||||||
this.manage = response.manage;
|
this.manage = response.manage;
|
||||||
this.hidePasswords = response.hidePasswords;
|
this.hidePasswords = response.hidePasswords;
|
||||||
this.type = response.type;
|
this.type = response.type;
|
||||||
|
this.defaultUserCollectionEmail = response.defaultUserCollectionEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
|
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||||
|
|
||||||
export class CollectionRequest {
|
export abstract class BaseCollectionRequest {
|
||||||
name: string;
|
|
||||||
externalId: string | undefined;
|
externalId: string | undefined;
|
||||||
groups: SelectionReadOnlyRequest[] = [];
|
groups: SelectionReadOnlyRequest[] = [];
|
||||||
users: SelectionReadOnlyRequest[] = [];
|
users: SelectionReadOnlyRequest[] = [];
|
||||||
|
|
||||||
constructor(c: {
|
static isUpdate = (request: BaseCollectionRequest): request is UpdateCollectionRequest => {
|
||||||
name: EncString;
|
return request instanceof UpdateCollectionRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected constructor(c: {
|
||||||
users?: SelectionReadOnlyRequest[];
|
users?: SelectionReadOnlyRequest[];
|
||||||
groups?: SelectionReadOnlyRequest[];
|
groups?: SelectionReadOnlyRequest[];
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!c.name || !c.name.encryptedString) {
|
|
||||||
throw new Error("Name not provided for CollectionRequest.");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.name = c.name.encryptedString;
|
|
||||||
this.externalId = c.externalId;
|
this.externalId = c.externalId;
|
||||||
|
|
||||||
if (c.groups) {
|
if (c.groups) {
|
||||||
@@ -28,3 +25,36 @@ export class CollectionRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateCollectionRequest extends BaseCollectionRequest {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
constructor(c: {
|
||||||
|
name: EncString;
|
||||||
|
users?: SelectionReadOnlyRequest[];
|
||||||
|
groups?: SelectionReadOnlyRequest[];
|
||||||
|
externalId?: string;
|
||||||
|
}) {
|
||||||
|
super(c);
|
||||||
|
|
||||||
|
if (!c.name || !c.name.encryptedString) {
|
||||||
|
throw new Error("Name not provided for CollectionRequest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.name = c.name.encryptedString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCollectionRequest extends BaseCollectionRequest {
|
||||||
|
name: string | null;
|
||||||
|
|
||||||
|
constructor(c: {
|
||||||
|
name: EncString | null;
|
||||||
|
users?: SelectionReadOnlyRequest[];
|
||||||
|
groups?: SelectionReadOnlyRequest[];
|
||||||
|
externalId?: string;
|
||||||
|
}) {
|
||||||
|
super(c);
|
||||||
|
this.name = c.name?.encryptedString ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export class CollectionResponse extends BaseResponse {
|
|||||||
id: CollectionId;
|
id: CollectionId;
|
||||||
organizationId: OrganizationId;
|
organizationId: OrganizationId;
|
||||||
name: string;
|
name: string;
|
||||||
|
defaultUserCollectionEmail: string | undefined;
|
||||||
externalId: string | undefined;
|
externalId: string | undefined;
|
||||||
type: CollectionType = CollectionTypes.SharedCollection;
|
type: CollectionType = CollectionTypes.SharedCollection;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export class CollectionResponse extends BaseResponse {
|
|||||||
this.organizationId = this.getResponseProperty("OrganizationId");
|
this.organizationId = this.getResponseProperty("OrganizationId");
|
||||||
this.name = this.getResponseProperty("Name");
|
this.name = this.getResponseProperty("Name");
|
||||||
this.externalId = this.getResponseProperty("ExternalId");
|
this.externalId = this.getResponseProperty("ExternalId");
|
||||||
|
this.defaultUserCollectionEmail = this.getResponseProperty("DefaultUserCollectionEmail");
|
||||||
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
|
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("Collection", () => {
|
|||||||
manage: true,
|
manage: true,
|
||||||
hidePasswords: true,
|
hidePasswords: true,
|
||||||
type: CollectionTypes.DefaultUserCollection,
|
type: CollectionTypes.DefaultUserCollection,
|
||||||
|
defaultUserCollectionEmail: "defaultCollectionEmail",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
encService = mock<EncryptService>();
|
encService = mock<EncryptService>();
|
||||||
@@ -61,6 +62,7 @@ describe("Collection", () => {
|
|||||||
manage: true,
|
manage: true,
|
||||||
hidePasswords: true,
|
hidePasswords: true,
|
||||||
type: CollectionTypes.DefaultUserCollection,
|
type: CollectionTypes.DefaultUserCollection,
|
||||||
|
defaultUserCollectionEmail: "defaultCollectionEmail",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ describe("Collection", () => {
|
|||||||
collection.hidePasswords = false;
|
collection.hidePasswords = false;
|
||||||
collection.manage = true;
|
collection.manage = true;
|
||||||
collection.type = CollectionTypes.DefaultUserCollection;
|
collection.type = CollectionTypes.DefaultUserCollection;
|
||||||
|
collection.defaultUserCollectionEmail = "defaultCollectionEmail";
|
||||||
|
|
||||||
const key = makeSymmetricCryptoKey<OrgKey>();
|
const key = makeSymmetricCryptoKey<OrgKey>();
|
||||||
|
|
||||||
@@ -84,12 +87,13 @@ describe("Collection", () => {
|
|||||||
externalId: "extId",
|
externalId: "extId",
|
||||||
hidePasswords: false,
|
hidePasswords: false,
|
||||||
id: "id",
|
id: "id",
|
||||||
name: "encName",
|
_name: "encName",
|
||||||
organizationId: "orgId",
|
organizationId: "orgId",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
manage: true,
|
manage: true,
|
||||||
assigned: true,
|
assigned: true,
|
||||||
type: CollectionTypes.DefaultUserCollection,
|
type: CollectionTypes.DefaultUserCollection,
|
||||||
|
defaultUserCollectionEmail: "defaultCollectionEmail",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class Collection extends Domain {
|
|||||||
hidePasswords: boolean = false;
|
hidePasswords: boolean = false;
|
||||||
manage: boolean = false;
|
manage: boolean = false;
|
||||||
type: CollectionType = CollectionTypes.SharedCollection;
|
type: CollectionType = CollectionTypes.SharedCollection;
|
||||||
|
defaultUserCollectionEmail: string | undefined;
|
||||||
|
|
||||||
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
|
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
|
||||||
super();
|
super();
|
||||||
@@ -46,6 +47,7 @@ export class Collection extends Domain {
|
|||||||
collection.hidePasswords = obj.hidePasswords;
|
collection.hidePasswords = obj.hidePasswords;
|
||||||
collection.manage = obj.manage;
|
collection.manage = obj.manage;
|
||||||
collection.type = obj.type;
|
collection.type = obj.type;
|
||||||
|
collection.defaultUserCollectionEmail = obj.defaultUserCollectionEmail;
|
||||||
|
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export const NestingDelimiter = "/";
|
|||||||
export class CollectionView implements View, ITreeNodeObject {
|
export class CollectionView implements View, ITreeNodeObject {
|
||||||
id: CollectionId;
|
id: CollectionId;
|
||||||
organizationId: OrganizationId;
|
organizationId: OrganizationId;
|
||||||
name: string;
|
|
||||||
externalId: string | undefined;
|
externalId: string | undefined;
|
||||||
// readOnly applies to the items within a collection
|
// readOnly applies to the items within a collection
|
||||||
readOnly: boolean = false;
|
readOnly: boolean = false;
|
||||||
@@ -24,11 +23,22 @@ export class CollectionView implements View, ITreeNodeObject {
|
|||||||
manage: boolean = false;
|
manage: boolean = false;
|
||||||
assigned: boolean = false;
|
assigned: boolean = false;
|
||||||
type: CollectionType = CollectionTypes.SharedCollection;
|
type: CollectionType = CollectionTypes.SharedCollection;
|
||||||
|
defaultUserCollectionEmail: string | undefined;
|
||||||
|
|
||||||
|
private _name: string;
|
||||||
|
|
||||||
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
|
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
|
||||||
this.id = c.id;
|
this.id = c.id;
|
||||||
this.organizationId = c.organizationId;
|
this.organizationId = c.organizationId;
|
||||||
this.name = c.name;
|
this._name = c.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
set name(name: string) {
|
||||||
|
this._name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.defaultUserCollectionEmail ?? this._name;
|
||||||
}
|
}
|
||||||
|
|
||||||
canEditItems(org: Organization): boolean {
|
canEditItems(org: Organization): boolean {
|
||||||
@@ -83,6 +93,18 @@ export class CollectionView implements View, ITreeNodeObject {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
|
||||||
|
* that were DefaultUserCollections but where the relevant user has been offboarded.
|
||||||
|
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
|
||||||
|
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
|
||||||
|
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
|
||||||
|
* Do not edit or remove this unless you understand why.
|
||||||
|
*/
|
||||||
|
canEditName(org: Organization): boolean {
|
||||||
|
return this.canEdit(org) && !this.defaultUserCollectionEmail;
|
||||||
|
}
|
||||||
|
|
||||||
get isDefaultCollection() {
|
get isDefaultCollection() {
|
||||||
return this.type == CollectionTypes.DefaultUserCollection;
|
return this.type == CollectionTypes.DefaultUserCollection;
|
||||||
}
|
}
|
||||||
@@ -111,6 +133,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
|||||||
view.hidePasswords = collection.hidePasswords;
|
view.hidePasswords = collection.hidePasswords;
|
||||||
view.manage = collection.manage;
|
view.manage = collection.manage;
|
||||||
view.type = collection.type;
|
view.type = collection.type;
|
||||||
|
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +148,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
|||||||
view.externalId = collection.externalId;
|
view.externalId = collection.externalId;
|
||||||
view.type = collection.type;
|
view.type = collection.type;
|
||||||
view.assigned = collection.assigned;
|
view.assigned = collection.assigned;
|
||||||
|
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs";
|
import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
|
import {
|
||||||
|
getOrganizationById,
|
||||||
|
OrganizationService,
|
||||||
|
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||||
@@ -10,13 +14,15 @@ import { KeyService } from "@bitwarden/key-management";
|
|||||||
import { CollectionAdminService, CollectionService } from "../abstractions";
|
import { CollectionAdminService, CollectionService } from "../abstractions";
|
||||||
import {
|
import {
|
||||||
CollectionData,
|
CollectionData,
|
||||||
CollectionRequest,
|
|
||||||
CollectionAccessDetailsResponse,
|
CollectionAccessDetailsResponse,
|
||||||
CollectionDetailsResponse,
|
CollectionDetailsResponse,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
BulkCollectionAccessRequest,
|
BulkCollectionAccessRequest,
|
||||||
CollectionAccessSelectionView,
|
CollectionAccessSelectionView,
|
||||||
CollectionAdminView,
|
CollectionAdminView,
|
||||||
|
BaseCollectionRequest,
|
||||||
|
UpdateCollectionRequest,
|
||||||
|
CreateCollectionRequest,
|
||||||
} from "../models";
|
} from "../models";
|
||||||
|
|
||||||
export class DefaultCollectionAdminService implements CollectionAdminService {
|
export class DefaultCollectionAdminService implements CollectionAdminService {
|
||||||
@@ -25,6 +31,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
|||||||
private keyService: KeyService,
|
private keyService: KeyService,
|
||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private collectionService: CollectionService,
|
private collectionService: CollectionService,
|
||||||
|
private organizationService: OrganizationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> {
|
collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> {
|
||||||
@@ -45,27 +52,40 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
|
async update(
|
||||||
const request = await this.encrypt(collection, userId);
|
collection: CollectionAdminView,
|
||||||
|
userId: UserId,
|
||||||
|
): Promise<CollectionDetailsResponse> {
|
||||||
|
const request = await this.encrypt(collection, userId, true);
|
||||||
|
if (!BaseCollectionRequest.isUpdate(request)) {
|
||||||
|
throw new Error("Cannot update collection with CreateCollectionRequest.");
|
||||||
|
}
|
||||||
|
|
||||||
let response: CollectionDetailsResponse;
|
const response = await this.apiService.putCollection(
|
||||||
if (collection.id == null) {
|
|
||||||
response = await this.apiService.postCollection(collection.organizationId, request);
|
|
||||||
collection.id = response.id;
|
|
||||||
} else {
|
|
||||||
response = await this.apiService.putCollection(
|
|
||||||
collection.organizationId,
|
collection.organizationId,
|
||||||
collection.id,
|
collection.id,
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.updateLocalCollections(response, collection, userId);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.assigned) {
|
async create(
|
||||||
await this.collectionService.upsert(new CollectionData(response), userId);
|
collection: CollectionAdminView,
|
||||||
} else {
|
userId: UserId,
|
||||||
await this.collectionService.delete([collection.id as CollectionId], userId);
|
): Promise<CollectionDetailsResponse> {
|
||||||
|
const request = await this.encrypt(collection, userId, false);
|
||||||
|
if (BaseCollectionRequest.isUpdate(request)) {
|
||||||
|
throw new Error("Cannot create collection with UpdateCollectionRequest.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await this.apiService.postCollection(collection.organizationId, request);
|
||||||
|
collection.id = response.id;
|
||||||
|
|
||||||
|
await this.updateLocalCollections(response, collection, userId);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +93,16 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
|||||||
await this.apiService.deleteCollection(organizationId, collectionId);
|
await this.apiService.deleteCollection(organizationId, collectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateLocalCollections(
|
||||||
|
response: CollectionDetailsResponse,
|
||||||
|
collection: CollectionAdminView,
|
||||||
|
userId: UserId,
|
||||||
|
) {
|
||||||
|
response.assigned
|
||||||
|
? await this.collectionService.upsert(new CollectionData(response), userId)
|
||||||
|
: await this.collectionService.delete([collection.id as CollectionId], userId);
|
||||||
|
}
|
||||||
|
|
||||||
async bulkAssignAccess(
|
async bulkAssignAccess(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
collectionIds: string[],
|
collectionIds: string[],
|
||||||
@@ -118,10 +148,15 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return await Promise.all(promises);
|
const r = await Promise.all(promises);
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async encrypt(model: CollectionAdminView, userId: UserId): Promise<CollectionRequest> {
|
private async encrypt(
|
||||||
|
model: CollectionAdminView,
|
||||||
|
userId: UserId,
|
||||||
|
editMode: boolean,
|
||||||
|
): Promise<UpdateCollectionRequest | CreateCollectionRequest> {
|
||||||
if (!model.organizationId) {
|
if (!model.organizationId) {
|
||||||
throw new Error("Collection has no organization id.");
|
throw new Error("Collection has no organization id.");
|
||||||
}
|
}
|
||||||
@@ -154,14 +189,31 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
|
|||||||
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage),
|
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage),
|
||||||
);
|
);
|
||||||
|
|
||||||
const collectionRequest = new CollectionRequest({
|
if (editMode) {
|
||||||
|
const org = await firstValueFrom(
|
||||||
|
this.organizationService
|
||||||
|
.organizations$(userId)
|
||||||
|
.pipe(getOrganizationById(model.organizationId)),
|
||||||
|
);
|
||||||
|
if (org == null) {
|
||||||
|
throw new Error("No Organization found.");
|
||||||
|
}
|
||||||
|
return new UpdateCollectionRequest({
|
||||||
|
name: model.canEditName(org)
|
||||||
|
? await this.encryptService.encryptString(model.name, key)
|
||||||
|
: null,
|
||||||
|
externalId: model.externalId,
|
||||||
|
users,
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateCollectionRequest({
|
||||||
name: await this.encryptService.encryptString(model.name, key),
|
name: await this.encryptService.encryptString(model.name, key),
|
||||||
externalId: model.externalId,
|
externalId: model.externalId,
|
||||||
users,
|
users,
|
||||||
groups,
|
groups,
|
||||||
});
|
});
|
||||||
|
|
||||||
return collectionRequest;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export class DefaultCollectionService implements CollectionService {
|
|||||||
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
|
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
|
||||||
const nodes: TreeNode<CollectionView>[] = [];
|
const nodes: TreeNode<CollectionView>[] = [];
|
||||||
collections.forEach((c) => {
|
collections.forEach((c) => {
|
||||||
const collectionCopy = Object.assign(new CollectionView({ ...c }), c);
|
const collectionCopy = Object.assign(new CollectionView({ ...c, name: c.name }), c);
|
||||||
|
|
||||||
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import {
|
import {
|
||||||
CollectionAccessDetailsResponse,
|
CollectionAccessDetailsResponse,
|
||||||
CollectionDetailsResponse,
|
CollectionDetailsResponse,
|
||||||
CollectionRequest,
|
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
|
CreateCollectionRequest,
|
||||||
|
UpdateCollectionRequest,
|
||||||
} from "@bitwarden/admin-console/common";
|
} from "@bitwarden/admin-console/common";
|
||||||
|
|
||||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||||
@@ -270,12 +271,12 @@ export abstract class ApiService {
|
|||||||
): Promise<ListResponse<CollectionAccessDetailsResponse>>;
|
): Promise<ListResponse<CollectionAccessDetailsResponse>>;
|
||||||
abstract postCollection(
|
abstract postCollection(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
request: CollectionRequest,
|
request: CreateCollectionRequest,
|
||||||
): Promise<CollectionDetailsResponse>;
|
): Promise<CollectionDetailsResponse>;
|
||||||
abstract putCollection(
|
abstract putCollection(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
request: CollectionRequest,
|
request: UpdateCollectionRequest,
|
||||||
): Promise<CollectionDetailsResponse>;
|
): Promise<CollectionDetailsResponse>;
|
||||||
abstract deleteCollection(organizationId: string, id: string): Promise<any>;
|
abstract deleteCollection(organizationId: string, id: string): Promise<any>;
|
||||||
abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>;
|
abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { firstValueFrom } from "rxjs";
|
|||||||
import {
|
import {
|
||||||
CollectionAccessDetailsResponse,
|
CollectionAccessDetailsResponse,
|
||||||
CollectionDetailsResponse,
|
CollectionDetailsResponse,
|
||||||
CollectionRequest,
|
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
|
CreateCollectionRequest,
|
||||||
|
UpdateCollectionRequest,
|
||||||
} from "@bitwarden/admin-console/common";
|
} from "@bitwarden/admin-console/common";
|
||||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
@@ -727,7 +728,7 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
|
|
||||||
async postCollection(
|
async postCollection(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
request: CollectionRequest,
|
request: CreateCollectionRequest,
|
||||||
): Promise<CollectionDetailsResponse> {
|
): Promise<CollectionDetailsResponse> {
|
||||||
const r = await this.send(
|
const r = await this.send(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -742,7 +743,7 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
async putCollection(
|
async putCollection(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
request: CollectionRequest,
|
request: UpdateCollectionRequest,
|
||||||
): Promise<CollectionDetailsResponse> {
|
): Promise<CollectionDetailsResponse> {
|
||||||
const r = await this.send(
|
const r = await this.send(
|
||||||
"PUT",
|
"PUT",
|
||||||
|
|||||||
@@ -33,23 +33,24 @@ const createMockCollection = (
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
): CollectionView => {
|
): CollectionView => {
|
||||||
return {
|
const cv = new CollectionView({
|
||||||
id: id as CollectionId,
|
|
||||||
name,
|
name,
|
||||||
organizationId: organizationId as OrganizationId,
|
organizationId: organizationId as OrganizationId,
|
||||||
externalId: "",
|
id: id as CollectionId,
|
||||||
readOnly,
|
});
|
||||||
hidePasswords: false,
|
cv.readOnly = readOnly;
|
||||||
manage: true,
|
cv.manage = true;
|
||||||
assigned: true,
|
cv.type = CollectionTypes.DefaultUserCollection;
|
||||||
type: CollectionTypes.DefaultUserCollection,
|
cv.externalId = "";
|
||||||
isDefaultCollection: true,
|
cv.hidePasswords = false;
|
||||||
canEditItems: jest.fn().mockReturnValue(canEdit),
|
cv.assigned = true;
|
||||||
canEdit: jest.fn(),
|
cv.canEditName = jest.fn().mockReturnValue(true);
|
||||||
canDelete: jest.fn(),
|
cv.canEditItems = jest.fn().mockReturnValue(canEdit);
|
||||||
canViewCollectionInfo: jest.fn(),
|
cv.canEdit = jest.fn();
|
||||||
encrypt: jest.fn(),
|
cv.canDelete = jest.fn();
|
||||||
};
|
cv.canViewCollectionInfo = jest.fn();
|
||||||
|
|
||||||
|
return cv;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ItemDetailsSectionComponent", () => {
|
describe("ItemDetailsSectionComponent", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user