diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 55385ca0ce9..81930279184 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { + DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserBulkConfirmRequest, OrganizationUserBulkPublicKeyResponse, @@ -26,8 +27,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserService } from "../../services/organization-user/organization-user.service"; - import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; import { BulkUserDetails } from "./bulk-status.component"; @@ -54,7 +53,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { private organizationUserApiService: OrganizationUserApiService, protected i18nService: I18nService, private stateProvider: StateProvider, - private organizationUserService: OrganizationUserService, + private organizationUserService: DefaultOrganizationUserService, private configService: ConfigService, ) { super(keyService, encryptService, i18nService); diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts index 2ac2d31cd69..baaa33eeae9 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -2,4 +2,3 @@ export { OrganizationMembersService } from "./organization-members-service/organ export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; -export { OrganizationUserService } from "./organization-user/organization-user.service"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 6fd7de7b292..e856ab7afd1 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -10,6 +10,7 @@ import { OrganizationUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; @@ -20,7 +21,6 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; -import { BillingConstraintService } from "../../../../../billing/members/billing-constraint/billing-constraint.service"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { OrganizationUserService } from "../organization-user/organization-user.service"; @@ -34,7 +34,7 @@ describe("MemberActionsService", () => { let encryptService: MockProxy; let configService: MockProxy; let accountService: FakeAccountService; - let billingConstraintService: MockProxy; + let organizationMetadataService: MockProxy; const userId = newGuid() as UserId; const organizationId = newGuid() as OrganizationId; @@ -50,7 +50,7 @@ describe("MemberActionsService", () => { encryptService = mock(); configService = mock(); accountService = mockAccountServiceWith(userId); - billingConstraintService = mock(); + organizationMetadataService = mock(); mockOrganization = { id: organizationId, @@ -75,7 +75,7 @@ describe("MemberActionsService", () => { encryptService, configService, accountService, - billingConstraintService, + organizationMetadataService, ); }); @@ -251,7 +251,7 @@ describe("MemberActionsService", () => { expect(result).toEqual({ success: true }); expect(organizationUserService.confirmUser).toHaveBeenCalledWith( mockOrganization, - mockOrgUser, + mockOrgUser.id, publicKey, ); expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3697aba94ff..2913e90e6c0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@angular/core"; import { firstValueFrom, switchMap, map } from "rxjs"; import { + DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserBulkResponse, OrganizationUserConfirmRequest, @@ -21,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; -import { OrganizationUserService } from "../organization-user/organization-user.service"; export interface MemberActionResult { success: boolean; @@ -39,7 +39,7 @@ export class MemberActionsService { constructor( private organizationUserApiService: OrganizationUserApiService, - private organizationUserService: OrganizationUserService, + private organizationUserService: DefaultOrganizationUserService, private keyService: KeyService, private encryptService: EncryptService, private configService: ConfigService, @@ -129,7 +129,7 @@ export class MemberActionsService { await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) ) { await firstValueFrom( - this.organizationUserService.confirmUser(organization, user, publicKey), + this.organizationUserService.confirmUser(organization, user.id, publicKey), ); } else { const request = await firstValueFrom( diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts new file mode 100644 index 00000000000..e753184273e --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { UserId } from "@bitwarden/user-core"; + +import { AutoConfirmState } from "../models/auto-confirm-state.model"; + +export abstract class AutomaticUserConfirmationService { + /** + * @param userId + * @returns Observable an observable with the Auto Confirm user state for the provided userId. + **/ + abstract configuration$(userId: UserId): Observable; + /** + * Upserts the existing user state with a new configuration. + * @param userId + * @param config The new AutoConfirmState to upsert into the user state for the provided userId. + **/ + abstract upsert(userId: UserId, config: AutoConfirmState): Promise; + /** + * This will check if the feature is enabled, the organization plan feature UseAutomaticUserConfirmation is enabled + * and the the provided user has admin/owner/manage custom permission role. + * @param userId + * @returns Observable an observable with a boolean telling us if the provided user may confgure the auto confirm feature. + **/ + abstract canManageAutoConfirm$( + userId: UserId, + organizationId: OrganizationId, + ): Observable; + /** + * Calls the API endpoint to initiate automatic user confirmation. + * @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks. + * @param confirmingUserId The userId of the user being confirmed. + * @param organization the organization the user is being auto confirmed to. + **/ + abstract autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise; +} diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/index.ts b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts new file mode 100644 index 00000000000..87e284656ab --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm.service.abstraction"; diff --git a/libs/admin-console/src/common/auto-confirm/index.ts b/libs/admin-console/src/common/auto-confirm/index.ts new file mode 100644 index 00000000000..9187ccd39cf --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/index.ts @@ -0,0 +1,3 @@ +export * from "./abstractions"; +export * from "./models"; +export * from "./services"; diff --git a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts similarity index 84% rename from libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts rename to libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts index b97f980b644..c69db69746c 100644 --- a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts +++ b/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts @@ -1,4 +1,4 @@ -import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; +import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state"; export class AutoConfirmState { enabled: boolean; diff --git a/libs/admin-console/src/common/auto-confirm/models/index.ts b/libs/admin-console/src/common/auto-confirm/models/index.ts new file mode 100644 index 00000000000..a34c54c16aa --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/models/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm-state.model"; diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts new file mode 100644 index 00000000000..133dac758b4 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts @@ -0,0 +1,382 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; + +import { + DefaultOrganizationUserService, + OrganizationUserApiService, + OrganizationUserConfirmRequest, +} from "../../organization-user"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service"; + +describe("DefaultAutomaticUserConfirmationService", () => { + let service: DefaultAutomaticUserConfirmationService; + let configService: jest.Mocked; + let apiService: jest.Mocked; + let organizationUserService: jest.Mocked; + let stateProvider: FakeStateProvider; + let organizationService: jest.Mocked; + let organizationUserApiService: jest.Mocked; + + const mockUserId = Utils.newGuid() as UserId; + const mockConfirmingUserId = Utils.newGuid() as UserId; + const mockOrganizationId = Utils.newGuid() as OrganizationId; + let mockOrganization: Organization; + + beforeEach(() => { + configService = { + getFeatureFlag$: jest.fn(), + } as any; + + apiService = { + getUserPublicKey: jest.fn(), + } as any; + + organizationUserService = { + buildConfirmRequest: jest.fn(), + } as any; + + stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId)); + + organizationService = { + organizations$: jest.fn(), + } as any; + + organizationUserApiService = { + postOrganizationUserConfirm: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + DefaultAutomaticUserConfirmationService, + { provide: ConfigService, useValue: configService }, + { provide: ApiService, useValue: apiService }, + { provide: DefaultOrganizationUserService, useValue: organizationUserService }, + { provide: "StateProvider", useValue: stateProvider }, + { + provide: InternalOrganizationServiceAbstraction, + useValue: organizationService, + }, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + ], + }); + + service = new DefaultAutomaticUserConfirmationService( + configService, + apiService, + organizationUserService, + stateProvider, + organizationService, + organizationUserApiService, + ); + + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + + mockOrganization = new Organization(mockOrgData); + }); + + describe("configuration$", () => { + it("should return default AutoConfirmState when no state exists", async () => { + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config).toBeInstanceOf(AutoConfirmState); + expect(config.enabled).toBe(false); + expect(config.showSetupDialog).toBe(true); + }); + + it("should return stored AutoConfirmState when state exists", async () => { + const expectedConfig = new AutoConfirmState(); + expectedConfig.enabled = true; + expectedConfig.showSetupDialog = false; + expectedConfig.showBrowserNotification = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [mockUserId]: expectedConfig }, + mockUserId, + ); + + const config$ = service.configuration$(mockUserId); + const config = await firstValueFrom(config$); + + expect(config.enabled).toBe(true); + expect(config.showSetupDialog).toBe(false); + expect(config.showBrowserNotification).toBe(true); + }); + + it("should emit updates when state changes", async () => { + const config$ = service.configuration$(mockUserId); + const configs: AutoConfirmState[] = []; + + const subscription = config$.subscribe((config) => configs.push(config)); + + expect(configs[0].enabled).toBe(false); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + await stateProvider.setUserState(AUTO_CONFIRM_STATE, { [mockUserId]: newConfig }, mockUserId); + + expect(configs.length).toBeGreaterThan(1); + expect(configs[configs.length - 1].enabled).toBe(true); + + subscription.unsubscribe(); + }); + }); + + describe("upsert", () => { + it("should store new configuration for user", async () => { + const newConfig = new AutoConfirmState(); + newConfig.enabled = true; + newConfig.showSetupDialog = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + }); + + it("should update existing configuration for user", async () => { + const initialConfig = new AutoConfirmState(); + initialConfig.enabled = false; + + await service.upsert(mockUserId, initialConfig); + + const updatedConfig = new AutoConfirmState(); + updatedConfig.enabled = true; + updatedConfig.showSetupDialog = false; + + await service.upsert(mockUserId, updatedConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId].enabled).toBe(true); + expect(storedState![mockUserId].showSetupDialog).toBe(false); + }); + + it("should preserve other user configurations when updating", async () => { + const otherUserId = Utils.newGuid() as UserId; + const otherConfig = new AutoConfirmState(); + otherConfig.enabled = true; + + await stateProvider.setUserState( + AUTO_CONFIRM_STATE, + { [otherUserId]: otherConfig }, + mockUserId, + ); + + const newConfig = new AutoConfirmState(); + newConfig.enabled = false; + + await service.upsert(mockUserId, newConfig); + + const storedState = await firstValueFrom( + stateProvider.getUser(mockUserId, AUTO_CONFIRM_STATE).state$, + ); + + expect(storedState != null); + expect(storedState![mockUserId]).toEqual(newConfig); + expect(storedState![otherUserId]).toEqual(otherConfig); + }); + }); + + describe("canManageAutoConfirm$", () => { + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + }); + + it("should return true when feature flag is enabled and organization allows management", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(true); + }); + + it("should return false when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization canManageUsers is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without manageUsers permission + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + const permissions = new PermissionsApi(); + permissions.manageUsers = false; + mockOrgData.permissions = permissions; + const orgWithoutManageUsers = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutManageUsers]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization useAutomaticUserConfirmation is false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization without useAutomaticUserConfirmation + const mockOrgData = new OrganizationData({} as any, {} as any); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = false; + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + const orgWithoutAutoConfirm = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWithoutAutoConfirm]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when organization is not found", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const organizations$ = new BehaviorSubject([]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should use the correct feature flag", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + await firstValueFrom(canManage$); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm); + }); + }); + + describe("autoConfirmUser", () => { + const mockPublicKey = "mock-public-key-base64"; + const mockPublicKeyArray = new Uint8Array([1, 2, 3, 4]); + const mockConfirmRequest = { + key: "encrypted-key", + defaultUserCollectionName: "encrypted-collection", + } as OrganizationUserConfirmRequest; + + beforeEach(() => { + const organizations$ = new BehaviorSubject([mockOrganization]); + organizationService.organizations$.mockReturnValue(organizations$); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray); + organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest)); + organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); + }); + + it("should successfully auto-confirm a user", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(apiService.getUserPublicKey).toHaveBeenCalledWith(mockUserId); + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganizationId, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should not confirm user when canManageAutoConfirm returns false", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Cannot automatically confirm user (insufficient permissions)"); + + expect(apiService.getUserPublicKey).not.toHaveBeenCalled(); + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should build confirm request with organization and public key", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserService.buildConfirmRequest).toHaveBeenCalledWith( + mockOrganization, + mockPublicKeyArray, + ); + }); + + it("should call API with correct parameters", async () => { + await service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization); + + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganization.id, + mockConfirmingUserId, + mockConfirmRequest, + ); + }); + + it("should handle API errors gracefully", async () => { + const apiError = new Error("API Error"); + apiService.getUserPublicKey.mockRejectedValue(apiError); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("API Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + + it("should handle buildConfirmRequest errors gracefully", async () => { + const buildError = new Error("Build Error"); + organizationUserService.buildConfirmRequest.mockReturnValue(throwError(() => buildError)); + + await expect( + service.autoConfirmUser(mockUserId, mockConfirmingUserId, mockOrganization), + ).rejects.toThrow("Build Error"); + + expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts new file mode 100644 index 00000000000..a906a2ddc4a --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts @@ -0,0 +1,90 @@ +import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { + DefaultOrganizationUserService, + OrganizationUserApiService, +} from "../../organization-user"; +import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction"; +import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; + +export class DefaultAutomaticUserConfirmationService implements AutomaticUserConfirmationService { + constructor( + private configService: ConfigService, + private apiService: ApiService, + private organizationUserService: DefaultOrganizationUserService, + private stateProvider: StateProvider, + private organizationService: InternalOrganizationServiceAbstraction, + private organizationUserApiService: OrganizationUserApiService, + ) {} + private autoConfirmState(userId: UserId) { + return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE); + } + + configuration$(userId: UserId): Observable { + return this.autoConfirmState(userId).state$.pipe( + map((records) => records?.[userId] ?? new AutoConfirmState()), + ); + } + + async upsert(userId: UserId, config: AutoConfirmState): Promise { + await this.autoConfirmState(userId).update((records) => { + return { + ...records, + [userId]: config, + }; + }); + } + + canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable { + return combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.organizationService.organizations$(userId).pipe(getById(organizationId)), + ]).pipe( + map( + ([enabled, organization]) => + (enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ?? + false, + ), + ); + } + + async autoConfirmUser( + userId: UserId, + confirmingUserId: UserId, + organization: Organization, + ): Promise { + await firstValueFrom( + this.canManageAutoConfirm$(userId, organization.id).pipe( + map((canManage) => { + if (!canManage) { + throw new Error("Cannot automatically confirm user (insufficient permissions)"); + } + return canManage; + }), + switchMap(() => this.apiService.getUserPublicKey(userId)), + map((publicKeyResponse) => Utils.fromB64ToArray(publicKeyResponse.publicKey)), + switchMap((publicKey) => + this.organizationUserService.buildConfirmRequest(organization, publicKey), + ), + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( + organization.id, + confirmingUserId, + request, + ), + ), + ), + ); + } +} diff --git a/libs/admin-console/src/common/auto-confirm/services/index.ts b/libs/admin-console/src/common/auto-confirm/services/index.ts new file mode 100644 index 00000000000..305ae380848 --- /dev/null +++ b/libs/admin-console/src/common/auto-confirm/services/index.ts @@ -0,0 +1 @@ +export * from "./default-auto-confirm.service"; diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index edeff5aa314..37f79d56256 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1,2 +1,3 @@ -export * from "./organization-user"; +export * from "./auto-confirm"; export * from "./collections"; +export * from "./organization-user"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/index.ts b/libs/admin-console/src/common/organization-user/abstractions/index.ts index 01cd189b3dd..dc2788deead 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/index.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/index.ts @@ -1 +1,2 @@ export * from "./organization-user-api.service"; +export * from "./organization-user.service"; diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts index ff422231a12..71d228ff822 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts @@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService { request: OrganizationUserConfirmRequest, ): Promise; + /** + * Admin api for automatically confirming an organization user that + * has accepted their invitation + * @param organizationId - Identifier for the organization to confirm + * @param id - Organization user identifier + * @param request - Request details for confirming the user + */ + abstract postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise; + /** * Retrieve a list of the specified users' public keys * @param organizationId - Identifier for the organization to accept diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts new file mode 100644 index 00000000000..844a0f412be --- /dev/null +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts @@ -0,0 +1,45 @@ +import { Observable } from "rxjs"; + +import { + OrganizationUserConfirmRequest, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +export abstract class OrganizationUserService { + /** + * Builds a confirmation request for an organization user. + * @param organization - The organization the user belongs to + * @param publicKey - The user's public key + * @returns An observable that emits the confirmation request + */ + abstract buildConfirmRequest( + organization: Organization, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms a user in an organization. + * @param organization - The organization the user belongs to + * @param userId - The ID of the user to confirm + * @param publicKey - The user's public key + * @returns An observable that completes when the user is confirmed + */ + abstract confirmUser( + organization: Organization, + userId: string, + publicKey: Uint8Array, + ): Observable; + + /** + * Confirms multiple users in an organization. + * @param organization - The organization the users belong to + * @param userIdsWithKeys - Array of user IDs with their encrypted keys + * @returns An observable that emits the bulk confirmation response + */ + abstract bulkConfirmUsers( + organization: Organization, + userIdsWithKeys: { id: string; key: string }[], + ): Observable>; +} diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts index c16fba258ec..869d84a8c8e 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts @@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer ); } + postOrganizationUserAutoConfirm( + organizationId: string, + id: string, + request: OrganizationUserConfirmRequest, + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + organizationId + "/users/" + id + "/auto-confirm", + request, + true, + false, + ); + } + async postOrganizationUsersPublicKey( organizationId: string, ids: string[], diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts similarity index 91% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts index 2ae5aa4eb98..982fb3ca5e0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts @@ -19,12 +19,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { DefaultOrganizationUserService } from "./default-organization-user.service"; -import { OrganizationUserService } from "./organization-user.service"; - -describe("OrganizationUserService", () => { - let service: OrganizationUserService; +describe("DefaultOrganizationUserService", () => { + let service: DefaultOrganizationUserService; let keyService: jest.Mocked; let encryptService: jest.Mocked; let organizationUserApiService: jest.Mocked; @@ -34,9 +32,7 @@ describe("OrganizationUserService", () => { const mockOrganization = new Organization(); mockOrganization.id = "org-123" as OrganizationId; - const mockOrganizationUser = new OrganizationUserView(); - mockOrganizationUser.id = "user-123"; - + const mockUserId = "user-123"; const mockPublicKey = new Uint8Array(64) as CsprngArray; const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; @@ -77,7 +73,7 @@ describe("OrganizationUserService", () => { TestBed.configureTestingModule({ providers: [ - OrganizationUserService, + DefaultOrganizationUserService, { provide: KeyService, useValue: keyService }, { provide: EncryptService, useValue: encryptService }, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, @@ -86,7 +82,13 @@ describe("OrganizationUserService", () => { ], }); - service = TestBed.inject(OrganizationUserService); + service = new DefaultOrganizationUserService( + keyService, + encryptService, + organizationUserApiService, + accountService, + i18nService, + ); }); describe("confirmUser", () => { @@ -97,7 +99,7 @@ describe("OrganizationUserService", () => { }); it("should confirm a user successfully", (done) => { - service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({ + service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({ next: () => { expect(i18nService.t).toHaveBeenCalledWith("myItems"); @@ -112,7 +114,7 @@ describe("OrganizationUserService", () => { expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( mockOrganization.id, - mockOrganizationUser.id, + mockUserId, { key: mockEncryptedKey.encryptedString, defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts similarity index 80% rename from apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts rename to libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts index f59b377e26e..4f503a92675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { @@ -6,6 +5,7 @@ import { OrganizationUserBulkConfirmRequest, OrganizationUserApiService, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,12 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationUserView } from "../../../core/views/organization-user.view"; - -@Injectable({ - providedIn: "root", -}) -export class OrganizationUserService { +export class DefaultOrganizationUserService implements OrganizationUserService { constructor( protected keyService: KeyService, private encryptService: EncryptService, @@ -39,11 +34,10 @@ export class OrganizationUserService { ); } - confirmUser( + buildConfirmRequest( organization: Organization, - user: OrganizationUserView, publicKey: Uint8Array, - ): Observable { + ): Observable { const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization); const encryptedKey$ = this.orgKey$(organization).pipe( @@ -51,18 +45,22 @@ export class OrganizationUserService { ); return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe( - switchMap(([key, collectionName]) => { - const request: OrganizationUserConfirmRequest = { - key: key.encryptedString, - defaultUserCollectionName: collectionName.encryptedString, - }; + map(([key, collectionName]) => ({ + key: key.encryptedString, + defaultUserCollectionName: collectionName.encryptedString, + })), + ); + } - return this.organizationUserApiService.postOrganizationUserConfirm( + confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable { + return this.buildConfirmRequest(organization, publicKey).pipe( + switchMap((request) => + this.organizationUserApiService.postOrganizationUserConfirm( organization.id, - user.id, + userId, request, - ); - }), + ), + ), ); } diff --git a/libs/admin-console/src/common/organization-user/services/index.ts b/libs/admin-console/src/common/organization-user/services/index.ts index 6135236d6a6..929a9fcd39a 100644 --- a/libs/admin-console/src/common/organization-user/services/index.ts +++ b/libs/admin-console/src/common/organization-user/services/index.ts @@ -1 +1,2 @@ export * from "./default-organization-user-api.service"; +export * from "./default-organization-user.service";