1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[PM-20642] - [Vault] [Web App] Front End Changes to Enforce "Remove card item type policy" (#15097)

* add restricted item types service and apply it to filter web cipher

* code cleanup. add shareReplay

* account for multiple orgs when restricting item types

* restrict item types for specific orgs

* clean up logic. use policiesByType$

* track by item.type

* clean up filtering. prefer observable. do not exempt owners for restricted item types

* simplify in vault-filter. move item filter logic to vault. fix tests

* don't return early in filter-function
This commit is contained in:
Jordan Aasen
2025-06-11 09:30:12 -07:00
committed by GitHub
parent 8b42edf9dc
commit 1175da3845
15 changed files with 423 additions and 102 deletions

View File

@@ -12,6 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
@@ -51,6 +52,7 @@ export class VaultFilterComponent
protected dialogService: DialogService, protected dialogService: DialogService,
protected configService: ConfigService, protected configService: ConfigService,
protected accountService: AccountService, protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) { ) {
super( super(
vaultFilterService, vaultFilterService,
@@ -62,6 +64,7 @@ export class VaultFilterComponent
dialogService, dialogService,
configService, configService,
accountService, accountService,
restrictedItemTypesService,
); );
} }

View File

@@ -7,7 +7,7 @@ import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class RestrictedItemTypesPolicy extends BasePolicy { export class RestrictedItemTypesPolicy extends BasePolicy {
name = "restrictedItemTypesPolicy"; name = "restrictedItemTypesPolicy";
description = "restrictedItemTypesPolicyDesc"; description = "restrictedItemTypesPolicyDesc";
type = PolicyType.RestrictedItemTypesPolicy; type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent; component = RestrictedItemTypesPolicyComponent;
} }

View File

@@ -342,8 +342,6 @@ export class VaultItemsComponent {
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
const items: VaultItem[] = [].concat(collections).concat(ciphers); const items: VaultItem[] = [].concat(collections).concat(ciphers);
this.selection.clear();
// All ciphers are selectable, collections only if they can be edited or deleted // All ciphers are selectable, collections only if they can be edited or deleted
this.editableItems = items.filter( this.editableItems = items.filter(
(item) => (item) =>

View File

@@ -29,6 +29,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
import { PreloadedEnglishI18nModule } from "../../../core/tests"; import { PreloadedEnglishI18nModule } from "../../../core/tests";
@@ -125,6 +126,12 @@ export default {
}, },
}, },
}, },
{
provide: RestrictedItemTypesService,
useValue: {
restricted$: of([]), // No restricted item types for this story
},
},
], ],
}), }),
applicationConfig({ applicationConfig({

View File

@@ -1,8 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; import {
distinctUntilChanged,
firstValueFrom,
map,
merge,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
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";
@@ -16,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service";
import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service";
@@ -56,6 +64,45 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
return this.filters ? Object.values(this.filters) : []; return this.filters ? Object.values(this.filters) : [];
} }
allTypeFilters: CipherTypeFilter[] = [
{
id: "favorites",
name: this.i18nService.t("favorites"),
type: "favorites",
icon: "bwi-star",
},
{
id: "login",
name: this.i18nService.t("typeLogin"),
type: CipherType.Login,
icon: "bwi-globe",
},
{
id: "card",
name: this.i18nService.t("typeCard"),
type: CipherType.Card,
icon: "bwi-credit-card",
},
{
id: "identity",
name: this.i18nService.t("typeIdentity"),
type: CipherType.Identity,
icon: "bwi-id-card",
},
{
id: "note",
name: this.i18nService.t("note"),
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
},
];
get searchPlaceholder() { get searchPlaceholder() {
if (this.activeFilter.isFavorites) { if (this.activeFilter.isFavorites) {
return "searchFavorites"; return "searchFavorites";
@@ -107,12 +154,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected dialogService: DialogService, protected dialogService: DialogService,
protected configService: ConfigService, protected configService: ConfigService,
protected accountService: AccountService, protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {} ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.filters = await this.buildAllFilters(); this.filters = await this.buildAllFilters();
this.activeFilter.selectedCipherTypeNode = if (this.filters?.typeFilter?.data$) {
(await this.getDefaultFilter()) as TreeNode<CipherTypeFilter>; this.activeFilter.selectedCipherTypeNode = (await firstValueFrom(
this.filters?.typeFilter.data$,
)) as TreeNode<CipherTypeFilter>;
}
this.isLoaded = true; this.isLoaded = true;
// Without refactoring the entire component, we need to manually update the organization filter whenever the policies update // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update
@@ -133,6 +185,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
.subscribe((orgFilters) => { .subscribe((orgFilters) => {
if (!this.filters) {
return;
}
this.filters.organizationFilter = orgFilters; this.filters.organizationFilter = orgFilters;
}); });
} }
@@ -151,7 +206,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
if (!orgNode?.node.enabled) { if (!orgNode?.node.enabled) {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: null,
message: this.i18nService.t("disabledOrganizationFilterError"), message: this.i18nService.t("disabledOrganizationFilterError"),
}); });
const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id);
@@ -190,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
this.onEditFolder.emit(folder); this.onEditFolder.emit(folder);
}; };
async getDefaultFilter(): Promise<TreeNode<VaultFilterType>> {
return await firstValueFrom(this.filters?.typeFilter.data$);
}
async buildAllFilters(): Promise<VaultFilterList> { async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList; const builderFilter = {} as VaultFilterList;
builderFilter.organizationFilter = await this.addOrganizationFilter(); builderFilter.organizationFilter = await this.addOrganizationFilter();
@@ -225,7 +275,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
const addAction = !singleOrgPolicy const addAction = !singleOrgPolicy
? { text: "newOrganization", route: "/create-organization" } ? { text: "newOrganization", route: "/create-organization" }
: null; : undefined;
const orgFilterSection: VaultFilterSection = { const orgFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.organizationTree$, data$: this.vaultFilterService.organizationTree$,
@@ -233,7 +283,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: !(singleOrgPolicy && personalVaultPolicy), showHeader: !(singleOrgPolicy && personalVaultPolicy),
isSelectable: true, isSelectable: true,
}, },
action: this.applyOrganizationFilter, action: this.applyOrganizationFilter as (orgNode: TreeNode<VaultFilterType>) => Promise<void>,
options: { component: OrganizationOptionsComponent }, options: { component: OrganizationOptionsComponent },
add: addAction, add: addAction,
divider: true, divider: true,
@@ -243,55 +293,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
} }
protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise<VaultFilterSection> { protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise<VaultFilterSection> {
const allTypeFilters: CipherTypeFilter[] = [ const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" };
{
id: "favorites", const data$ = this.restrictedItemTypesService.restricted$.pipe(
name: this.i18nService.t("favorites"), map((restricted) => {
type: "favorites", // List of types restricted by all orgs
icon: "bwi-star", const restrictedByAll = restricted
}, .filter((r) => r.allowViewOrgIds.length === 0)
{ .map((r) => r.cipherType);
id: "login", const toExclude = [...excludeTypes, ...restrictedByAll];
name: this.i18nService.t("typeLogin"), return this.allTypeFilters.filter(
type: CipherType.Login, (f) => typeof f.type !== "string" && !toExclude.includes(f.type),
icon: "bwi-globe", );
}, }),
{ switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)),
id: "card", distinctUntilChanged(),
name: this.i18nService.t("typeCard"), shareReplay({ bufferSize: 1, refCount: true }),
type: CipherType.Card, );
icon: "bwi-credit-card",
},
{
id: "identity",
name: this.i18nService.t("typeIdentity"),
type: CipherType.Identity,
icon: "bwi-id-card",
},
{
id: "note",
name: this.i18nService.t("note"),
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
{
id: "sshKey",
name: this.i18nService.t("typeSshKey"),
type: CipherType.SshKey,
icon: "bwi-key",
},
];
const typeFilterSection: VaultFilterSection = { const typeFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree( data$,
{ id: "AllItems", name: "allItems", type: "all", icon: "" },
allTypeFilters.filter((f) => !excludeTypes.includes(f.type)),
),
header: { header: {
showHeader: true, showHeader: true,
isSelectable: true, isSelectable: true,
}, },
action: this.applyTypeFilter, action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
}; };
return typeFilterSection; return typeFilterSection;
} }
@@ -303,10 +329,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: true, showHeader: true,
isSelectable: false, isSelectable: false,
}, },
action: this.applyFolderFilter, action: this.applyFolderFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
edit: { edit: {
filterName: this.i18nService.t("folder"), filterName: this.i18nService.t("folder"),
action: this.editFolder, action: this.editFolder as (filter: VaultFilterType) => void,
}, },
}; };
return folderFilterSection; return folderFilterSection;
@@ -319,7 +345,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: true, showHeader: true,
isSelectable: true, isSelectable: true,
}, },
action: this.applyCollectionFilter, action: this.applyCollectionFilter as (
filterNode: TreeNode<VaultFilterType>,
) => Promise<void>,
}; };
return collectionFilterSection; return collectionFilterSection;
} }
@@ -346,7 +374,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
showHeader: false, showHeader: false,
isSelectable: true, isSelectable: true,
}, },
action: this.applyTypeFilter, action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
}; };
return trashFilterSection; return trashFilterSection;
} }

View File

@@ -3,6 +3,7 @@
import { Unassigned } from "@bitwarden/admin-console/common"; import { Unassigned } from "@bitwarden/admin-console/common";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedCipherType } from "@bitwarden/vault";
import { createFilterFunction } from "./filter-function"; import { createFilterFunction } from "./filter-function";
import { All } from "./routed-vault-filter.model"; import { All } from "./routed-vault-filter.model";
@@ -214,6 +215,46 @@ describe("createFilter", () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe("given restricted types", () => {
const restrictedTypes: RestrictedCipherType[] = [
{ cipherType: CipherType.Login, allowViewOrgIds: [] },
];
it("should filter out a cipher whose type is fully restricted", () => {
const cipher = createCipher({ type: CipherType.Login });
const filterFunction = createFilterFunction({}, restrictedTypes);
expect(filterFunction(cipher)).toBe(false);
});
it("should allow a cipher when the cipher's organization allows it", () => {
const cipher = createCipher({ type: CipherType.Login, organizationId: "org1" });
const restricted: RestrictedCipherType[] = [
{ cipherType: CipherType.Login, allowViewOrgIds: ["org1"] },
];
const filterFunction2 = createFilterFunction({}, restricted);
expect(filterFunction2(cipher)).toBe(true);
});
it("should filter out a personal vault cipher when the owning orgs does not allow it", () => {
const cipher = createCipher({ type: CipherType.Card, organizationId: "org1" });
const restricted2: RestrictedCipherType[] = [
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
];
const filterFunction3 = createFilterFunction({}, restricted2);
expect(filterFunction3(cipher)).toBe(false);
});
it("should not filter a cipher if there are no restricted types", () => {
const cipher = createCipher({ type: CipherType.Login });
const filterFunction = createFilterFunction({}, []);
expect(filterFunction(cipher)).toBe(true);
});
});
}); });
function createCipher(options: Partial<CipherView> = {}) { function createCipher(options: Partial<CipherView> = {}) {

View File

@@ -1,12 +1,16 @@
import { Unassigned } from "@bitwarden/admin-console/common"; import { Unassigned } from "@bitwarden/admin-console/common";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedCipherType } from "@bitwarden/vault";
import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model";
export type FilterFunction = (cipher: CipherView) => boolean; export type FilterFunction = (cipher: CipherView) => boolean;
export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { export function createFilterFunction(
filter: RoutedVaultFilterModel,
restrictedTypes?: RestrictedCipherType[],
): FilterFunction {
return (cipher) => { return (cipher) => {
if (filter.type === "favorites" && !cipher.favorite) { if (filter.type === "favorites" && !cipher.favorite) {
return false; return false;
@@ -80,6 +84,24 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc
return false; return false;
} }
// Restricted types
if (restrictedTypes && restrictedTypes.length > 0) {
// Filter the cipher if that type is restricted unless
// - The cipher belongs to an organization and that organization allows viewing the cipher type
// OR
// - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type
if (
restrictedTypes.some(
(restrictedType) =>
restrictedType.cipherType === cipher.type &&
(cipher.organizationId
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
: restrictedType.allowViewOrgIds.length === 0),
)
) {
return false;
}
}
return true; return true;
}; };
} }

View File

@@ -81,26 +81,12 @@
{{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i> {{ "new" | i18n }}<i class="bwi tw-ml-2" aria-hidden="true"></i>
</button> </button>
<bit-menu #addOptions aria-labelledby="newItemDropdown"> <bit-menu #addOptions aria-labelledby="newItemDropdown">
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)"> @for (item of cipherMenuItems$ | async; track item.type) {
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i> <button type="button" bitMenuItem (click)="addCipher(item.type)">
{{ "typeLogin" | i18n }} <i class="bwi {{ item.icon }}" slot="start" aria-hidden="true"></i>
</button> {{ item.labelKey | i18n }}
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)"> </button>
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i> }
{{ "typeCard" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</button>
<bit-menu-divider /> <bit-menu-divider />
<button type="button" bitMenuItem (click)="addFolder()"> <button type="button" bitMenuItem (click)="addFolder()">
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>

View File

@@ -1,16 +1,9 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
} from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs"; import { firstValueFrom, map, shareReplay } from "rxjs";
import { import {
Unassigned, Unassigned,
@@ -31,6 +24,7 @@ import {
MenuModule, MenuModule,
SimpleDialogOptions, SimpleDialogOptions,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog"; import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog";
import { HeaderModule } from "../../../layouts/header/header.module"; import { HeaderModule } from "../../../layouts/header/header.module";
@@ -55,11 +49,26 @@ import {
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class VaultHeaderComponent implements OnInit { export class VaultHeaderComponent {
protected Unassigned = Unassigned; protected Unassigned = Unassigned;
protected All = All; protected All = All;
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
protected CipherType = CipherType; protected CipherType = CipherType;
protected allCipherMenuItems = [
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" },
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
];
protected cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => {
return this.allCipherMenuItems.filter((item) => {
return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type);
});
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
/** /**
* Boolean to determine the loading state of the header. * Boolean to determine the loading state of the header.
@@ -100,10 +109,9 @@ export class VaultHeaderComponent implements OnInit {
private dialogService: DialogService, private dialogService: DialogService,
private router: Router, private router: Router,
private configService: ConfigService, private configService: ConfigService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {} ) {}
async ngOnInit() {}
/** /**
* The id of the organization that is currently being filtered on. * The id of the organization that is currently being filtered on.
* This can come from a collection filter or organization filter, if applied. * This can come from a collection filter or organization filter, if applied.

View File

@@ -79,6 +79,7 @@ import {
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
PasswordRepromptService, PasswordRepromptService,
RestrictedItemTypesService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { import {
@@ -273,6 +274,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService, private billingNotificationService: BillingNotificationService,
private configService: ConfigService, private configService: ConfigService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -356,12 +358,13 @@ export class VaultComponent implements OnInit, OnDestroy {
this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)), this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)),
filter$, filter$,
this.currentSearchText$, this.currentSearchText$,
this.restrictedItemTypesService.restricted$,
]).pipe( ]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => { concatMap(async ([ciphers, filter, searchText, restrictedTypes]) => {
const failedCiphers = const failedCiphers =
(await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? []; (await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? [];
const filterFunction = createFilterFunction(filter); const filterFunction = createFilterFunction(filter, restrictedTypes);
// Append any failed to decrypt ciphers to the top of the cipher list // Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers]; const allCiphers = [...failedCiphers, ...ciphers];

View File

@@ -16,5 +16,5 @@ export enum PolicyType {
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN. RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
RestrictedItemTypesPolicy = 15, // Restricts item types that can be created within an organization RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
} }

View File

@@ -228,15 +228,19 @@ export class DefaultPolicyService implements PolicyService {
case PolicyType.MaximumVaultTimeout: case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners // Max Vault Timeout applies to everyone except owners
return organization.isOwner; return organization.isOwner;
// the following policies apply to everyone
case PolicyType.PasswordGenerator: case PolicyType.PasswordGenerator:
// password generation policy applies to everyone // password generation policy
return false;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy
return false;
case PolicyType.RestrictedItemTypes:
// restricted item types policy
return false; return false;
case PolicyType.PersonalOwnership: case PolicyType.PersonalOwnership:
// individual vault policy applies to everyone except admins and owners // individual vault policy applies to everyone except admins and owners
return organization.isAdmin; return organization.isAdmin;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy applies to everyone
return false;
default: default:
return organization.canManagePolicies; return organization.canManagePolicies;
} }

View File

@@ -24,6 +24,10 @@ export * as VaultIcons from "./icons";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export {
RestrictedItemTypesService,
RestrictedCipherType,
} from "./services/restricted-item-types.service";
export * from "./abstractions/change-login-password.service"; export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service"; export * from "./services/default-change-login-password.service";

View File

@@ -0,0 +1,137 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { RestrictedItemTypesService, RestrictedCipherType } from "./restricted-item-types.service";
describe("RestrictedItemTypesService", () => {
let service: RestrictedItemTypesService;
let policyService: MockProxy<PolicyService>;
let organizationService: MockProxy<OrganizationService>;
let accountService: MockProxy<AccountService>;
let configService: MockProxy<ConfigService>;
let fakeAccount: Account | null;
const org1: Organization = { id: "org1" } as any;
const org2: Organization = { id: "org2" } as any;
const policyOrg1 = {
organizationId: "org1",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: [CipherType.Card],
} as Policy;
const policyOrg2 = {
organizationId: "org2",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: [CipherType.Card],
} as Policy;
beforeEach(() => {
policyService = mock<PolicyService>();
organizationService = mock<OrganizationService>();
accountService = mock<AccountService>();
configService = mock<ConfigService>();
fakeAccount = { id: Utils.newGuid() as UserId } as Account;
accountService.activeAccount$ = of(fakeAccount);
TestBed.configureTestingModule({
providers: [
{ provide: PolicyService, useValue: policyService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: AccountService, useValue: accountService },
{ provide: ConfigService, useValue: configService },
],
});
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(of([]));
service = TestBed.inject(RestrictedItemTypesService);
});
it("emits empty array when feature flag is disabled", async () => {
configService.getFeatureFlag$.mockReturnValue(of(false));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual([]);
});
it("emits empty array if no organizations exist", async () => {
organizationService.organizations$.mockReturnValue(of([]));
policyService.policiesByType$.mockReturnValue(of([]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual([]);
});
it("defaults undefined data to [Card] and returns empty allowViewOrgIds", async () => {
organizationService.organizations$.mockReturnValue(of([org1]));
const policyForOrg1 = {
organizationId: "org1",
type: PolicyType.RestrictedItemTypes,
enabled: true,
data: undefined,
} as Policy;
policyService.policiesByType$.mockReturnValue(of([policyForOrg1]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
});
it("if one org restricts Card and another has no policy, allowViewOrgIds contains the unrestricted org", async () => {
policyService.policiesByType$.mockReturnValue(of([policyOrg1]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: ["org2"] },
]);
});
it("returns empty allowViewOrgIds when all orgs restrict the same type", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(of([policyOrg1, policyOrg2]));
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
});
it("aggregates multiple types and computes allowViewOrgIds correctly", async () => {
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(
of([
{ ...policyOrg1, data: [CipherType.Card, CipherType.Login] } as Policy,
{ ...policyOrg2, data: [CipherType.Card, CipherType.Identity] } as Policy,
]),
);
const result = await firstValueFrom(service.restricted$);
expect(result).toEqual<RestrictedCipherType[]>([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
{ cipherType: CipherType.Login, allowViewOrgIds: ["org2"] },
{ cipherType: CipherType.Identity, allowViewOrgIds: ["org1"] },
]);
});
});

View File

@@ -0,0 +1,80 @@
import { Injectable } from "@angular/core";
import { combineLatest, map, of, Observable } from "rxjs";
import { switchMap, distinctUntilChanged, shareReplay } from "rxjs/operators";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherType } from "@bitwarden/common/vault/enums";
export type RestrictedCipherType = {
cipherType: CipherType;
allowViewOrgIds: string[];
};
@Injectable({ providedIn: "root" })
export class RestrictedItemTypesService {
/**
* Emits an array of RestrictedCipherType objects:
* - cipherType: each type restricted by at least one org-level policy
* - allowViewOrgIds: org IDs that allow viewing that type
*/
readonly restricted$: Observable<RestrictedCipherType[]> = this.configService
.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy)
.pipe(
switchMap((flagOn) => {
if (!flagOn) {
return of([]);
}
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
combineLatest([
this.organizationService.organizations$(userId),
this.policyService.policiesByType$(PolicyType.RestrictedItemTypes, userId),
]),
),
map(([orgs, enabledPolicies]) => {
// Helper to extract restricted types, defaulting to [Card]
const restrictedTypes = (p: (typeof enabledPolicies)[number]) =>
(p.data as CipherType[]) ?? [CipherType.Card];
// Union across all enabled policies
const allRestrictedTypes = Array.from(
new Set(enabledPolicies.flatMap(restrictedTypes)),
);
return allRestrictedTypes.map((cipherType) => {
// Determine which orgs allow viewing this type
const allowViewOrgIds = orgs
.filter((org) => {
const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id);
// no policy for this org => allows everything
if (!orgPolicy) {
return true;
}
// if this type not in their restricted list => they allow it
return !restrictedTypes(orgPolicy).includes(cipherType);
})
.map((org) => org.id);
return { cipherType, allowViewOrgIds };
});
}),
);
}),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
constructor(
private configService: ConfigService,
private accountService: AccountService,
private organizationService: OrganizationService,
private policyService: PolicyService,
) {}
}