From 80d5b3314d42c4f0fae0044e6bdb4bc39637b5df Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Mon, 23 Sep 2024 21:30:04 -0500 Subject: [PATCH] add admin console implementation of `AdminConsoleCipherFormConfigService` --- ...console-cipher-form-config.service.spec.ts | 133 ++++++++++++++++++ ...dmin-console-cipher-form-config.service.ts | 121 ++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts create mode 100644 apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts new file mode 100644 index 00000000000..70e26463178 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -0,0 +1,133 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; + +import { CollectionAdminService } from "../../core/collection-admin.service"; +import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; + +import { AdminConsoleCipherFormConfigService } from "./admin-console-cipher-form-config.service"; + +describe("AdminConsoleCipherFormConfigService", () => { + let adminConsoleConfigService: AdminConsoleCipherFormConfigService; + + const cipherId = "333-444-555" as CipherId; + const testOrg = { id: "333-44-55", name: "Test Org", canEditAllCiphers: false }; + const policyAppliesToActiveUser$ = new BehaviorSubject(true); + const organization$ = new BehaviorSubject(testOrg as Organization); + const getCipherAdmin = jest.fn().mockResolvedValue(null); + const getCipher = jest.fn().mockResolvedValue(null); + + beforeEach(async () => { + getCipherAdmin.mockClear(); + getCipher.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + AdminConsoleCipherFormConfigService, + { + provide: PolicyService, + useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, + }, + { provide: OrganizationService, useValue: { get$: () => organization$ } }, + { provide: CipherService, useValue: { get: getCipher } }, + { provide: CollectionService, useValue: { getAllDecrypted: () => Promise.resolve([]) } }, + { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } }, + { + provide: RoutedVaultFilterService, + useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) }, + }, + { provide: ApiService, useValue: { getCipherAdmin } }, + ], + }); + }); + + describe("buildConfig", () => { + it("sets folder attributes", async () => { + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + const { folders, hideFolderSelection } = await adminConsoleConfigService.buildConfig( + "add", + cipherId, + ); + + expect(folders).toEqual([]); + expect(hideFolderSelection).toBe(true); + }); + + it("sets mode based on passed mode", async () => { + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + const { mode } = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(mode).toBe("edit"); + }); + + it("sets admin flag based on `canEditAllCiphers`", async () => { + // Disable edit all ciphers on org + testOrg.canEditAllCiphers = false; + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + let result = await adminConsoleConfigService.buildConfig("add", cipherId); + + expect(result.admin).toBe(false); + + // Enable edit all ciphers on org + testOrg.canEditAllCiphers = true; + result = await adminConsoleConfigService.buildConfig("add", cipherId); + + expect(result.admin).toBe(true); + }); + + it("sets `allowPersonalOwnership`", async () => { + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + policyAppliesToActiveUser$.next(true); + + let result = await adminConsoleConfigService.buildConfig("clone", cipherId); + + expect(result.allowPersonalOwnership).toBe(false); + + policyAppliesToActiveUser$.next(false); + + result = await adminConsoleConfigService.buildConfig("clone", cipherId); + + expect(result.allowPersonalOwnership).toBe(true); + }); + + describe("getCipher", () => { + it("retrieves the cipher from the cipher service", async () => { + getCipher.mockResolvedValueOnce({ id: cipherId, name: "Test Cipher - (non-admin)" }); + testOrg.canEditAllCiphers = false; + + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + const result = await adminConsoleConfigService.buildConfig("clone", cipherId); + + expect(getCipher).toHaveBeenCalledWith(cipherId); + expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)"); + + // Admin service not needed when cipher service can return the cipher + expect(getCipherAdmin).not.toHaveBeenCalled(); + }); + + it("retrieves the cipher from the admin service", async () => { + getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); + + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + await adminConsoleConfigService.buildConfig("add", cipherId); + + expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); + + expect(getCipher).toHaveBeenCalledWith(cipherId); + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts new file mode 100644 index 00000000000..51f4da4d3f9 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -0,0 +1,121 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, defer, filter, firstValueFrom, map, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +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 { CipherId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; + +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, +} from "../../../../../../../libs/vault/src/cipher-form/abstractions/cipher-form-config.service"; +import { CollectionAdminService } from "../../core/collection-admin.service"; +import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; + +/** Admin Console implementation of the `CipherFormConfigService`. */ +@Injectable() +export class AdminConsoleCipherFormConfigService implements CipherFormConfigService { + private policyService: PolicyService = inject(PolicyService); + private organizationService: OrganizationService = inject(OrganizationService); + private cipherService: CipherService = inject(CipherService); + private collectionService: CollectionService = inject(CollectionService); + private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService); + private collectionAdminService: CollectionAdminService = inject(CollectionAdminService); + private apiService: ApiService = inject(ApiService); + + private allowPersonalOwnership$ = this.policyService + .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + .pipe(map((p) => !p)); + + private organizationId$ = this.routedVaultFilterService.filter$.pipe( + map((filter) => filter.organizationId), + filter((filter) => filter !== undefined), + ); + + private organization$ = this.organizationId$.pipe( + switchMap((organizationId) => this.organizationService.get$(organizationId)), + ); + + private allCollectionsWithoutUnassigned$ = combineLatest([ + this.organizationId$.pipe(switchMap((orgId) => this.collectionAdminService.getAll(orgId))), + defer(() => this.collectionService.getAllDecrypted()), + ]).pipe( + map(([adminCollections, syncCollections]) => { + const syncCollectionDict = Object.fromEntries(syncCollections.map((c) => [c.id, c])); + + return adminCollections.map((collection) => { + const currentId: any = collection.id; + + const match = syncCollectionDict[currentId]; + + if (match) { + collection.manage = match.manage; + collection.readOnly = match.readOnly; + collection.hidePasswords = match.hidePasswords; + } + return collection; + }); + }), + ); + + async buildConfig( + mode: CipherFormMode, + cipherId?: CipherId, + cipherType?: CipherType, + ): Promise { + const [organization, allowPersonalOwnership, allCollections] = await firstValueFrom( + combineLatest([ + this.organization$, + this.allowPersonalOwnership$, + this.allCollectionsWithoutUnassigned$, + ]), + ); + + const cipher = await this.getCipher(organization, cipherId); + + const collections = allCollections.filter( + (c) => c.organizationId === organization.id && c.assigned && !c.readOnly, + ); + + return { + mode, + cipherType: cipher?.type ?? cipherType ?? CipherType.Login, + admin: organization.canEditAllCiphers ?? false, + allowPersonalOwnership, + originalCipher: cipher, + collections, + organizations: [organization], // only a single org is in context at a time + folders: [], // folders not applicable in the admin console + hideFolderSelection: true, + }; + } + + private async getCipher(organization: Organization, id?: CipherId): Promise { + if (id == null) { + return Promise.resolve(null); + } + + // Check to see if the user has direct access to the cipher + const cipherFromCipherService = await this.cipherService.get(id); + + // If the organization doesn't allow admin/owners to edit all ciphers return the cipher + if (!organization.canEditAllCiphers && cipherFromCipherService != null) { + return cipherFromCipherService; + } + + // Retrieve the cipher through the means of an admin + const cipherResponse = await this.apiService.getCipherAdmin(id); + const cipherData = new CipherData(cipherResponse); + + return new Cipher(cipherData); + } +}