1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-20643] - [Vault] [Desktop] Front End Changes to Enforce "Remove card item type policy" (#15176)

* add restricted item types to legacy vault components

* filter out restricted item types from new menu item in desktop

* use CIPHER_MENU_ITEMS

* use CIPHER_MENU_ITEMS. move restricted cipher service to common

* use move restricted item types service to libs. re-use cipher menu items

* add shareReplay. change variable name

* move restricted filter to search service. remove unecessary import

* add reusable service method

* clean up spec

* add optional chain

* remove duplicate import

* move isCipherViewRestricted to service module

* fix logic

* fix logic

* remove extra space

---------

Co-authored-by: SmithThe4th <gsmith@bitwarden.com>
This commit is contained in:
Jordan Aasen
2025-06-18 12:13:38 -07:00
committed by GitHub
parent 8d4fc91590
commit 5fa153e743
29 changed files with 166 additions and 171 deletions

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ITreeNodeObject, TreeNode } from "./models/domain/tree-node";
export class ServiceUtils {

View File

@@ -0,0 +1,131 @@
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);
configService.getFeatureFlag$.mockReturnValue(of(true));
organizationService.organizations$.mockReturnValue(of([org1, org2]));
policyService.policiesByType$.mockReturnValue(of([]));
service = new RestrictedItemTypesService(
configService,
accountService,
organizationService,
policyService,
);
});
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 () => {
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 () => {
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,101 @@
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";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
export type RestrictedCipherType = {
cipherType: CipherType;
allowViewOrgIds: string[];
};
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,
) {}
}
/**
* Filter that returns whether a cipher is restricted from being viewed by the user
* Criteria:
* - the cipher's type is restricted by at least one org
* UNLESS
* - the cipher belongs to an organization and that organization does not restrict that type
* OR
* - the cipher belongs to the user's personal vault and at least one other organization does not restrict that type
*/
export function isCipherViewRestricted(
cipher: CipherView,
restrictedTypes: RestrictedCipherType[],
) {
return restrictedTypes.some(
(restrictedType) =>
restrictedType.cipherType === cipher.type &&
(cipher.organizationId
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
: restrictedType.allowViewOrgIds.length === 0),
);
}

View File

@@ -19,6 +19,6 @@ export const CIPHER_MENU_ITEMS = Object.freeze([
{ 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.SecureNote, icon: "bwi-sticky-note", labelKey: "typeNote" },
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
] as const) satisfies readonly CipherMenuItem[];