1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-02 19:41:26 +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

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

View File

@@ -148,6 +148,19 @@ export abstract class OrganizationUserApiService {
request: OrganizationUserConfirmRequest,
): 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
* @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(
organizationId: string,
ids: string[],

View File

@@ -0,0 +1,177 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
OrganizationUserConfirmRequest,
OrganizationUserBulkConfirmRequest,
OrganizationUserApiService,
OrganizationUserBulkResponse,
} from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { DefaultOrganizationUserService } from "./default-organization-user.service";
describe("DefaultOrganizationUserService", () => {
let service: DefaultOrganizationUserService;
let keyService: jest.Mocked<KeyService>;
let encryptService: jest.Mocked<EncryptService>;
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let accountService: jest.Mocked<AccountService>;
let i18nService: jest.Mocked<I18nService>;
const mockOrganization = new Organization();
mockOrganization.id = "org-123" as OrganizationId;
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;
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
const mockDefaultCollectionName = "My Items";
const setupCommonMocks = () => {
keyService.orgKeys$.mockReturnValue(
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
);
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
i18nService.t.mockReturnValue(mockDefaultCollectionName);
};
beforeEach(() => {
keyService = {
orgKeys$: jest.fn(),
} as any;
encryptService = {
encryptString: jest.fn(),
encapsulateKeyUnsigned: jest.fn(),
} as any;
organizationUserApiService = {
postOrganizationUserConfirm: jest.fn(),
postOrganizationUserBulkConfirm: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" }),
} as any;
i18nService = {
t: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
DefaultOrganizationUserService,
{ provide: KeyService, useValue: keyService },
{ provide: EncryptService, useValue: encryptService },
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: i18nService },
],
});
service = new DefaultOrganizationUserService(
keyService,
encryptService,
organizationUserApiService,
accountService,
i18nService,
);
});
describe("confirmUser", () => {
beforeEach(() => {
setupCommonMocks();
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
});
it("should confirm a user successfully", (done) => {
service.confirmUser(mockOrganization, mockUserId, mockPublicKey).subscribe({
next: () => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
mockOrgKey,
mockPublicKey,
);
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
mockOrganization.id,
mockUserId,
{
key: mockEncryptedKey.encryptedString,
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
} as OrganizationUserConfirmRequest,
);
done();
},
error: done,
});
});
});
describe("bulkConfirmUsers", () => {
const mockUserIdsWithKeys = [
{ id: "user-1", key: "key-1" },
{ id: "user-2", key: "key-2" },
];
const mockBulkResponse = {
data: [
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
],
} as ListResponse<OrganizationUserBulkResponse>;
beforeEach(() => {
setupCommonMocks();
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
Promise.resolve(mockBulkResponse),
);
});
it("should bulk confirm users successfully", (done) => {
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
next: (response) => {
expect(i18nService.t).toHaveBeenCalledWith("myItems");
expect(encryptService.encryptString).toHaveBeenCalledWith(
mockDefaultCollectionName,
mockOrgKey,
);
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
mockOrganization.id,
new OrganizationUserBulkConfirmRequest(
mockUserIdsWithKeys,
mockEncryptedCollectionName.encryptedString,
),
);
expect(response).toEqual(mockBulkResponse);
done();
},
error: done,
});
});
});
});

View File

@@ -0,0 +1,93 @@
import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
import {
OrganizationUserConfirmRequest,
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";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
export class DefaultOrganizationUserService implements OrganizationUserService {
constructor(
protected keyService: KeyService,
private encryptService: EncryptService,
private organizationUserApiService: OrganizationUserApiService,
private accountService: AccountService,
private i18nService: I18nService,
) {}
private orgKey$(organization: Organization) {
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
filter((orgKeys) => !!orgKeys),
map((organizationKeysById) => organizationKeysById[organization.id as OrganizationId]),
);
}
buildConfirmRequest(
organization: Organization,
publicKey: Uint8Array,
): Observable<OrganizationUserConfirmRequest> {
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
const encryptedKey$ = this.orgKey$(organization).pipe(
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
);
return combineLatest([encryptedKey$, encryptedCollectionName$]).pipe(
map(([key, collectionName]) => ({
key: key.encryptedString,
defaultUserCollectionName: collectionName.encryptedString,
})),
);
}
confirmUser(organization: Organization, userId: string, publicKey: Uint8Array): Observable<void> {
return this.buildConfirmRequest(organization, publicKey).pipe(
switchMap((request) =>
this.organizationUserApiService.postOrganizationUserConfirm(
organization.id,
userId,
request,
),
),
);
}
bulkConfirmUsers(
organization: Organization,
userIdsWithKeys: { id: string; key: string }[],
): Observable<ListResponse<OrganizationUserBulkResponse>> {
return this.getEncryptedDefaultCollectionName$(organization).pipe(
switchMap((collectionName) => {
const request = new OrganizationUserBulkConfirmRequest(
userIdsWithKeys,
collectionName.encryptedString,
);
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
organization.id,
request,
);
}),
);
}
private getEncryptedDefaultCollectionName$(organization: Organization) {
return this.orgKey$(organization).pipe(
switchMap((orgKey) =>
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
),
);
}
}

View File

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