1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[PM-26372] Add auto confirm service (#17001)

* add state definition for auto confirm

* typo

* refactor organziation user service

* WIP create auto confirm service

* add POST method, finish implementation

* add missing userId param, jsdoc

* fix DI

* refactor organziation user service

* WIP create auto confirm service

* add POST method, finish implementation

* add missing userId param, jsdoc

* clean up, more DI fixes

* remove @Injectable from service, fix tests

* remove from libs/common, fix dir structure, add tests
This commit is contained in:
Brandon Treston
2025-10-28 09:47:54 -04:00
committed by GitHub
parent af061282c6
commit 8162c06700
20 changed files with 638 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import { import {
DefaultOrganizationUserService,
OrganizationUserApiService, OrganizationUserApiService,
OrganizationUserBulkConfirmRequest, OrganizationUserBulkConfirmRequest,
OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkPublicKeyResponse,
@@ -26,8 +27,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
import { BulkUserDetails } from "./bulk-status.component"; import { BulkUserDetails } from "./bulk-status.component";
@@ -54,7 +53,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
private organizationUserApiService: OrganizationUserApiService, private organizationUserApiService: OrganizationUserApiService,
protected i18nService: I18nService, protected i18nService: I18nService,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private organizationUserService: OrganizationUserService, private organizationUserService: DefaultOrganizationUserService,
private configService: ConfigService, private configService: ConfigService,
) { ) {
super(keyService, encryptService, i18nService); super(keyService, encryptService, i18nService);

View File

@@ -2,4 +2,3 @@ export { OrganizationMembersService } from "./organization-members-service/organ
export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberActionsService } from "./member-actions/member-actions.service";
export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service";
export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service";
export { OrganizationUserService } from "./organization-user/organization-user.service";

View File

@@ -10,6 +10,7 @@ import {
OrganizationUserStatusType, OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums"; } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; 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 { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management"; 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 { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service"; import { OrganizationUserService } from "../organization-user/organization-user.service";
@@ -34,7 +34,7 @@ describe("MemberActionsService", () => {
let encryptService: MockProxy<EncryptService>; let encryptService: MockProxy<EncryptService>;
let configService: MockProxy<ConfigService>; let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService; let accountService: FakeAccountService;
let billingConstraintService: MockProxy<BillingConstraintService>; let organizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
const userId = newGuid() as UserId; const userId = newGuid() as UserId;
const organizationId = newGuid() as OrganizationId; const organizationId = newGuid() as OrganizationId;
@@ -50,7 +50,7 @@ describe("MemberActionsService", () => {
encryptService = mock<EncryptService>(); encryptService = mock<EncryptService>();
configService = mock<ConfigService>(); configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId); accountService = mockAccountServiceWith(userId);
billingConstraintService = mock<BillingConstraintService>(); organizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganization = { mockOrganization = {
id: organizationId, id: organizationId,
@@ -75,7 +75,7 @@ describe("MemberActionsService", () => {
encryptService, encryptService,
configService, configService,
accountService, accountService,
billingConstraintService, organizationMetadataService,
); );
}); });
@@ -251,7 +251,7 @@ describe("MemberActionsService", () => {
expect(result).toEqual({ success: true }); expect(result).toEqual({ success: true });
expect(organizationUserService.confirmUser).toHaveBeenCalledWith( expect(organizationUserService.confirmUser).toHaveBeenCalledWith(
mockOrganization, mockOrganization,
mockOrgUser, mockOrgUser.id,
publicKey, publicKey,
); );
expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled();

View File

@@ -2,6 +2,7 @@ import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs"; import { firstValueFrom, switchMap, map } from "rxjs";
import { import {
DefaultOrganizationUserService,
OrganizationUserApiService, OrganizationUserApiService,
OrganizationUserBulkResponse, OrganizationUserBulkResponse,
OrganizationUserConfirmRequest, OrganizationUserConfirmRequest,
@@ -21,7 +22,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { OrganizationUserService } from "../organization-user/organization-user.service";
export interface MemberActionResult { export interface MemberActionResult {
success: boolean; success: boolean;
@@ -39,7 +39,7 @@ export class MemberActionsService {
constructor( constructor(
private organizationUserApiService: OrganizationUserApiService, private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService, private organizationUserService: DefaultOrganizationUserService,
private keyService: KeyService, private keyService: KeyService,
private encryptService: EncryptService, private encryptService: EncryptService,
private configService: ConfigService, private configService: ConfigService,
@@ -129,7 +129,7 @@ export class MemberActionsService {
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
) { ) {
await firstValueFrom( await firstValueFrom(
this.organizationUserService.confirmUser(organization, user, publicKey), this.organizationUserService.confirmUser(organization, user.id, publicKey),
); );
} else { } else {
const request = await firstValueFrom( const request = await firstValueFrom(

View File

@@ -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<AutoConfirmState> an observable with the Auto Confirm user state for the provided userId.
**/
abstract configuration$(userId: UserId): Observable<AutoConfirmState>;
/**
* 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<void>;
/**
* 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<boolean> an observable with a boolean telling us if the provided user may confgure the auto confirm feature.
**/
abstract canManageAutoConfirm$(
userId: UserId,
organizationId: OrganizationId,
): Observable<boolean>;
/**
* 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<void>;
}

View File

@@ -0,0 +1 @@
export * from "./auto-confirm.service.abstraction";

View File

@@ -0,0 +1,3 @@
export * from "./abstractions";
export * from "./models";
export * from "./services";

View File

@@ -1,4 +1,4 @@
import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; import { AUTO_CONFIRM, UserKeyDefinition } from "@bitwarden/state";
export class AutoConfirmState { export class AutoConfirmState {
enabled: boolean; enabled: boolean;

View File

@@ -0,0 +1 @@
export * from "./auto-confirm-state.model";

View File

@@ -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<ConfigService>;
let apiService: jest.Mocked<ApiService>;
let organizationUserService: jest.Mocked<DefaultOrganizationUserService>;
let stateProvider: FakeStateProvider;
let organizationService: jest.Mocked<InternalOrganizationServiceAbstraction>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
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<Organization[]>([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<Organization[]>([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<Organization[]>([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<Organization[]>([]);
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<Organization[]>([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();
});
});
});

View File

@@ -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<AutoConfirmState> {
return this.autoConfirmState(userId).state$.pipe(
map((records) => records?.[userId] ?? new AutoConfirmState()),
);
}
async upsert(userId: UserId, config: AutoConfirmState): Promise<void> {
await this.autoConfirmState(userId).update((records) => {
return {
...records,
[userId]: config,
};
});
}
canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable<boolean> {
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<void> {
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,
),
),
),
);
}
}

View File

@@ -0,0 +1 @@
export * from "./default-auto-confirm.service";

View File

@@ -1,2 +1,3 @@
export * from "./organization-user"; export * from "./auto-confirm";
export * from "./collections"; export * from "./collections";
export * from "./organization-user";

View File

@@ -1 +1,2 @@
export * from "./organization-user-api.service"; export * from "./organization-user-api.service";
export * from "./organization-user.service";

View File

@@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService {
request: OrganizationUserConfirmRequest, request: OrganizationUserConfirmRequest,
): Promise<void>; ): Promise<void>;
/**
* 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<void>;
/** /**
* Retrieve a list of the specified users' public keys * Retrieve a list of the specified users' public keys
* @param organizationId - Identifier for the organization to accept * @param organizationId - Identifier for the organization to accept

View File

@@ -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<OrganizationUserConfirmRequest>;
/**
* 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<void>;
/**
* 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<ListResponse<OrganizationUserBulkResponse>>;
}

View File

@@ -194,6 +194,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
); );
} }
postOrganizationUserAutoConfirm(
organizationId: string,
id: string,
request: OrganizationUserConfirmRequest,
): Promise<void> {
return this.apiService.send(
"POST",
"/organizations/" + organizationId + "/users/" + id + "/auto-confirm",
request,
true,
false,
);
}
async postOrganizationUsersPublicKey( async postOrganizationUsersPublicKey(
organizationId: string, organizationId: string,
ids: string[], ids: string[],

View File

@@ -19,12 +19,10 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key"; import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management"; 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("DefaultOrganizationUserService", () => {
let service: DefaultOrganizationUserService;
describe("OrganizationUserService", () => {
let service: OrganizationUserService;
let keyService: jest.Mocked<KeyService>; let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>; let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>; let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
@@ -34,9 +32,7 @@ describe("OrganizationUserService", () => {
const mockOrganization = new Organization(); const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId; mockOrganization.id = "org-123" as OrganizationId;
const mockOrganizationUser = new OrganizationUserView(); const mockUserId = "user-123";
mockOrganizationUser.id = "user-123";
const mockPublicKey = new Uint8Array(64) as CsprngArray; const mockPublicKey = new Uint8Array(64) as CsprngArray;
const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
@@ -77,7 +73,7 @@ describe("OrganizationUserService", () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
OrganizationUserService, DefaultOrganizationUserService,
{ provide: KeyService, useValue: keyService }, { provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService }, { provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: OrganizationUserApiService, useValue: organizationUserApiService },
@@ -86,7 +82,13 @@ describe("OrganizationUserService", () => {
], ],
}); });
service = TestBed.inject(OrganizationUserService); service = new DefaultOrganizationUserService(
keyService,
encryptService,
organizationUserApiService,
accountService,
i18nService,
);
}); });
describe("confirmUser", () => { describe("confirmUser", () => {
@@ -97,7 +99,7 @@ describe("OrganizationUserService", () => {
}); });
it("should confirm a user successfully", (done) => { it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({ service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({
next: () => { next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems"); expect(i18nService.t).toHaveBeenCalledWith("myItems");
@@ -112,7 +114,7 @@ describe("OrganizationUserService", () => {
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id, mockOrganization.id,
mockOrganizationUser.id, mockUserId,
{ {
key: mockEncryptedKey.encryptedString, key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,

View File

@@ -1,4 +1,3 @@
import { Injectable } from "@angular/core";
import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import { import {
@@ -6,6 +5,7 @@ import {
OrganizationUserBulkConfirmRequest, OrganizationUserBulkConfirmRequest,
OrganizationUserApiService, OrganizationUserApiService,
OrganizationUserBulkResponse, OrganizationUserBulkResponse,
OrganizationUserService,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; 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 { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view"; export class DefaultOrganizationUserService implements OrganizationUserService {
@Injectable({
providedIn: "root",
})
export class OrganizationUserService {
constructor( constructor(
protected keyService: KeyService, protected keyService: KeyService,
private encryptService: EncryptService, private encryptService: EncryptService,
@@ -39,11 +34,10 @@ export class OrganizationUserService {
); );
} }
confirmUser( buildConfirmRequest(
organization: Organization, organization: Organization,
user: OrganizationUserView,
publicKey: Uint8Array, publicKey: Uint8Array,
): Observable<void> { ): Observable<OrganizationUserConfirmRequest> {
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization); const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe( const encryptedKey$ = this.orgKey$(organization).pipe(
@@ -51,18 +45,22 @@ export class OrganizationUserService {
); );
return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe( return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe(
switchMap(([key, collectionName]) => { map(([key, collectionName]) => ({
const request: OrganizationUserConfirmRequest = { key: key.encryptedString,
key: key.encryptedString, defaultUserCollectionName: collectionName.encryptedString,
defaultUserCollectionName: collectionName.encryptedString, })),
}; );
}
return this.organizationUserApiService.postOrganizationUserConfirm( confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable<void> {
return this.buildConfirmRequest(organization, publicKey).pipe(
switchMap((request) =>
this.organizationUserApiService.postOrganizationUserConfirm(
organization.id, organization.id,
user.id, userId,
request, request,
); ),
}), ),
); );
} }

View File

@@ -1 +1,2 @@
export * from "./default-organization-user-api.service"; export * from "./default-organization-user-api.service";
export * from "./default-organization-user.service";