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 index f0624e6b2f..02d280f5ff 100644 --- 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 @@ -4,6 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { CollectionAdminService } from "@bitwarden/admin-console/common"; 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 { OrganizationUserStatusType } 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"; @@ -16,8 +18,25 @@ describe("AdminConsoleCipherFormConfigService", () => { let adminConsoleConfigService: AdminConsoleCipherFormConfigService; const cipherId = "333-444-555" as CipherId; - const testOrg = { id: "333-44-55", name: "Test Org", canEditAllCiphers: false }; + const testOrg = { + id: "333-44-55", + name: "Test Org", + canEditAllCiphers: false, + isMember: true, + enabled: true, + status: OrganizationUserStatusType.Confirmed, + }; + const testOrg2 = { + id: "333-999-888", + name: "Test Org 2", + canEditAllCiphers: false, + isMember: true, + enabled: true, + status: OrganizationUserStatusType.Confirmed, + }; + const policyAppliesToActiveUser$ = new BehaviorSubject(true); const organization$ = new BehaviorSubject(testOrg as Organization); + const organizations$ = new BehaviorSubject([testOrg, testOrg2] as Organization[]); const getCipherAdmin = jest.fn().mockResolvedValue(null); const getCipher = jest.fn().mockResolvedValue(null); @@ -30,7 +49,11 @@ describe("AdminConsoleCipherFormConfigService", () => { await TestBed.configureTestingModule({ providers: [ AdminConsoleCipherFormConfigService, - { provide: OrganizationService, useValue: { get$: () => organization$ } }, + { + provide: PolicyService, + useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, + }, + { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, { provide: CipherService, useValue: { get: getCipher } }, { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } }, { @@ -79,12 +102,55 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(result.admin).toBe(true); }); - it("sets `allowPersonalOwnership` to false", async () => { + it("sets `allowPersonalOwnership`", async () => { adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - const result = await adminConsoleConfigService.buildConfig("clone", cipherId); + 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); + }); + + it("disables personal ownership when not cloning", async () => { + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + + policyAppliesToActiveUser$.next(false); + + let result = await adminConsoleConfigService.buildConfig("add", cipherId); + + expect(result.allowPersonalOwnership).toBe(false); + + result = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(result.allowPersonalOwnership).toBe(false); + + result = await adminConsoleConfigService.buildConfig("clone", cipherId); + + expect(result.allowPersonalOwnership).toBe(true); + }); + + it("returns all ciphers when cloning a cipher", async () => { + // Add cipher + let result = await adminConsoleConfigService.buildConfig("add", cipherId); + + expect(result.organizations).toEqual([testOrg]); + + // Edit cipher + result = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(result.organizations).toEqual([testOrg]); + + // Clone cipher + result = await adminConsoleConfigService.buildConfig("clone", cipherId); + + expect(result.organizations).toEqual([testOrg, testOrg2]); }); describe("getCipher", () => { 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 index fa5cbedfca..328ab4475d 100644 --- 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 @@ -4,6 +4,8 @@ import { combineLatest, filter, firstValueFrom, map, switchMap } from "rxjs"; import { CollectionAdminService } from "@bitwarden/admin-console/common"; 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, OrganizationUserStatusType } 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"; @@ -21,23 +23,40 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se /** 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 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 allOrganizations$ = this.organizationService.organizations$.pipe( + map((orgs) => { + return orgs.filter( + (o) => o.isMember && o.enabled && o.status === OrganizationUserStatusType.Confirmed, + ); + }), + ); + + private organization$ = combineLatest([this.allOrganizations$, this.organizationId$]).pipe( + map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)), ); private editableCollections$ = this.organization$.pipe( switchMap(async (org) => { + if (!org) { + return []; + } + const collections = await this.collectionAdminService.getAll(org.id); // Users that can edit all ciphers can implicitly add to / edit within any collection if (org.canEditAllCiphers) { @@ -53,26 +72,39 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ cipherId?: CipherId, cipherType?: CipherType, ): Promise { - const [organization, allCollections] = await firstValueFrom( - combineLatest([this.organization$, this.editableCollections$]), - ); + const [organization, allowPersonalOwnership, allOrganizations, allCollections] = + await firstValueFrom( + combineLatest([ + this.organization$, + this.allowPersonalOwnership$, + this.allOrganizations$, + this.editableCollections$, + ]), + ); const cipher = await this.getCipher(organization, cipherId); const collections = allCollections.filter( (c) => c.organizationId === organization.id && c.assigned && !c.readOnly, ); + // When cloning from within the Admin Console, all organizations should be available. + // Otherwise only the one in context should be + const organizations = mode === "clone" ? allOrganizations : [organization]; + // Only allow the user to assign to their personal vault when cloning and + // the policies are enabled for it. + const allowPersonalOwnershipOnlyForClone = mode === "clone" ? allowPersonalOwnership : false; return { mode, cipherType: cipher?.type ?? cipherType ?? CipherType.Login, admin: organization.canEditAllCiphers ?? false, - allowPersonalOwnership: false, + allowPersonalOwnership: allowPersonalOwnershipOnlyForClone, originalCipher: cipher, collections, - organizations: [organization], // only a single org is in context at a time + organizations, folders: [], // folders not applicable in the admin console hideIndividualVaultFields: true, + isAdminConsole: true, }; } diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts index f00aacf963..3fc473c446 100644 --- a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts @@ -82,6 +82,9 @@ type BaseCipherFormConfig = { /** Hides the fields that are only applicable to individuals, useful in the Admin Console where folders aren't applicable */ hideIndividualVaultFields?: true; + + /** True when the config is built within the context of the Admin Console */ + isAdminConsole?: true; }; /** diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index fb193dd3dd..86a8818bbe 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -163,9 +163,13 @@ export class ItemDetailsSectionComponent implements OnInit { } get showOwnership() { - return ( - this.allowOwnershipChange || (this.organizations.length > 0 && this.config.mode === "edit") - ); + // Show ownership field when editing with available orgs + const isEditingWithOrgs = this.organizations.length > 0 && this.config.mode === "edit"; + + // When in admin console, ownership should not be shown unless cloning + const isAdminConsoleEdit = this.config.isAdminConsole && this.config.mode !== "clone"; + + return this.allowOwnershipChange || (isEditingWithOrgs && !isAdminConsoleEdit); } get defaultOwner() {