1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

Initial impl of sharing operations with SDK

This commit is contained in:
Nik Gilmore
2026-01-23 09:57:22 -08:00
parent 79a585c0a2
commit 945c26f9da
8 changed files with 545 additions and 10 deletions

View File

@@ -61,6 +61,7 @@ export enum FeatureFlag {
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM28190CipherSharingOpsToSdk = "pm-28190-cipher-sharing-ops-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
@@ -69,6 +70,7 @@ export enum FeatureFlag {
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
PM28190_SdkCipherShareOperations = "pm-28190-cipher-sharing-ops-to-sdk",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -132,6 +134,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.PM28190_SdkCipherShareOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */

View File

@@ -1,4 +1,5 @@
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
/**
@@ -138,4 +139,40 @@ export abstract class CipherSdkService {
userId: UserId,
includeMemberItems: boolean,
): Promise<CipherView[]>;
/**
* Shares a cipher with an organization using the SDK.
* Handles encryption and API call in one operation.
*
* @param cipherView The cipher view to share
* @param organizationId The organization to share with
* @param collectionIds The collection IDs to add the cipher to
* @param userId The user ID to use for SDK client
* @param originalCipherView Optional original cipher view for password history tracking
* @returns A promise that resolves to the shared cipher (encrypted)
*/
abstract shareWithServer(
cipherView: CipherView,
organizationId: OrganizationId,
collectionIds: CollectionId[],
userId: UserId,
originalCipherView?: CipherView,
): Promise<Cipher>;
/**
* Shares multiple ciphers with an organization using the SDK.
* Handles encryption and API calls in one operation.
*
* @param cipherViews The cipher views to share
* @param organizationId The organization to share with
* @param collectionIds The collection IDs to add the ciphers to
* @param userId The user ID to use for SDK client
* @returns A promise that resolves to the shared ciphers (encrypted)
*/
abstract shareManyWithServer(
cipherViews: CipherView[],
organizationId: OrganizationId,
collectionIds: CollectionId[],
userId: UserId,
): Promise<Cipher[]>;
}

View File

@@ -145,14 +145,14 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* @param organizationId The Id of the organization to move the cipher to
* @param collectionIds The collection Ids to assign the cipher to in the organization
* @param userId The Id of the user performing the operation
* @param originalCipher Optional original cipher that will be used to compare/update password history
* @param originalCipherView Optional original cipher view that will be used to compare/update password history
*/
abstract shareWithServer(
cipher: CipherView,
organizationId: string,
collectionIds: string[],
userId: UserId,
originalCipher?: Cipher,
originalCipherView?: CipherView,
): Promise<Cipher>;
abstract shareManyWithServer(
ciphers: CipherView[],

View File

@@ -3,7 +3,8 @@ import { of } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { UserId, CipherId, OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherType } from "../enums/cipher-type";
@@ -46,6 +47,8 @@ describe("DefaultCipherSdkService", () => {
restore: jest.fn().mockResolvedValue(undefined),
restore_many: jest.fn().mockResolvedValue(undefined),
list: jest.fn().mockResolvedValue({ successes: [], failures: [] }),
share_cipher: jest.fn(),
share_ciphers_bulk: jest.fn(),
admin: jest.fn().mockReturnValue(mockAdminSdk),
};
mockVaultSdk = {
@@ -664,4 +667,225 @@ describe("DefaultCipherSdkService", () => {
);
});
});
describe("shareWithServer()", () => {
const collectionId1 = "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CollectionId;
const collectionId2 = "7ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b24" as CollectionId;
const createMockSdkCipher = (id: string): any => ({
id: id,
organizationId: orgId,
folderId: null,
collectionIds: [],
key: null,
name: "EncryptedString",
notes: null,
type: CipherType.Login,
login: null,
identity: null,
card: null,
secureNote: null,
sshKey: null,
data: null,
favorite: false,
reprompt: 0,
organizationUseTotp: false,
edit: true,
permissions: null,
viewPassword: true,
localData: null,
attachments: null,
fields: null,
passwordHistory: null,
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
archivedDate: null,
revisionDate: "2022-01-31T12:00:00.000Z",
});
it("should share cipher using SDK", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Test Cipher";
const mockSdkCipher = createMockSdkCipher(cipherId);
mockCiphersSdk.share_cipher.mockResolvedValue(mockSdkCipher);
const result = await cipherSdkService.shareWithServer(
cipherView,
orgId,
[collectionId1, collectionId2],
userId,
);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.share_cipher).toHaveBeenCalledWith(
expect.objectContaining({
name: cipherView.name,
}),
orgId,
[collectionId1, collectionId2],
null,
);
expect(result).toBeInstanceOf(Cipher);
});
it("should pass null for original cipher when originalCipherView is provided", async () => {
const cipherView = new CipherView();
cipherView.id = cipherId;
cipherView.type = CipherType.Login;
cipherView.name = "Test Cipher";
const originalCipherView = new CipherView();
originalCipherView.id = cipherId;
originalCipherView.name = "Original Cipher";
const mockSdkCipher = createMockSdkCipher(cipherId);
mockCiphersSdk.share_cipher.mockResolvedValue(mockSdkCipher);
await cipherSdkService.shareWithServer(
cipherView,
orgId,
[collectionId1],
userId,
originalCipherView,
);
// SDK handles history internally, so we always pass null for original_cipher
expect(mockCiphersSdk.share_cipher).toHaveBeenCalledWith(
expect.anything(),
orgId,
[collectionId1],
null,
);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
await expect(
cipherSdkService.shareWithServer(cipherView, orgId, [collectionId1], userId),
).rejects.toThrow("SDK not available");
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to share cipher"),
);
});
it("should throw error and log when SDK throws an error", async () => {
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
mockCiphersSdk.share_cipher.mockRejectedValue(new Error("SDK error"));
await expect(
cipherSdkService.shareWithServer(cipherView, orgId, [collectionId1], userId),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to share cipher"),
);
});
});
describe("shareManyWithServer()", () => {
const collectionId1 = "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CollectionId;
const cipherId2 = "8ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b25" as CipherId;
const createMockSdkCipher = (id: string): any => ({
id: id,
organizationId: orgId,
folderId: null,
collectionIds: [],
key: null,
name: "EncryptedString",
notes: null,
type: CipherType.Login,
login: null,
identity: null,
card: null,
secureNote: null,
sshKey: null,
data: null,
favorite: false,
reprompt: 0,
organizationUseTotp: false,
edit: true,
permissions: null,
viewPassword: true,
localData: null,
attachments: null,
fields: null,
passwordHistory: null,
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
archivedDate: null,
revisionDate: "2022-01-31T12:00:00.000Z",
});
it("should share multiple ciphers using SDK", async () => {
const cipherView1 = new CipherView();
cipherView1.id = cipherId;
cipherView1.type = CipherType.Login;
cipherView1.name = "Test Cipher 1";
const cipherView2 = new CipherView();
cipherView2.id = cipherId2;
cipherView2.type = CipherType.Login;
cipherView2.name = "Test Cipher 2";
const mockSdkCiphers = [createMockSdkCipher(cipherId), createMockSdkCipher(cipherId2)];
mockCiphersSdk.share_ciphers_bulk.mockResolvedValue(mockSdkCiphers);
const result = await cipherSdkService.shareManyWithServer(
[cipherView1, cipherView2],
orgId,
[collectionId1],
userId,
);
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
expect(mockCiphersSdk.share_ciphers_bulk).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ name: cipherView1.name }),
expect.objectContaining({ name: cipherView2.name }),
]),
orgId,
[collectionId1],
);
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(Cipher);
expect(result[1]).toBeInstanceOf(Cipher);
});
it("should throw error and log when SDK client is not available", async () => {
sdkService.userClient$.mockReturnValue(of(null));
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
await expect(
cipherSdkService.shareManyWithServer([cipherView], orgId, [collectionId1], userId),
).rejects.toThrow("SDK not available");
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to share multiple ciphers"),
);
});
it("should throw error and log when SDK throws an error", async () => {
const cipherView = new CipherView();
cipherView.name = "Test Cipher";
mockCiphersSdk.share_ciphers_bulk.mockRejectedValue(new Error("SDK error"));
await expect(
cipherSdkService.shareManyWithServer([cipherView], orgId, [collectionId1], userId),
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining("Failed to share multiple ciphers"),
);
});
});
});

View File

@@ -3,7 +3,7 @@ import { firstValueFrom, switchMap, catchError } from "rxjs";
import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
@@ -336,4 +336,77 @@ export class DefaultCipherSdkService implements CipherSdkService {
),
);
}
async shareWithServer(
cipherView: CipherView,
organizationId: OrganizationId,
collectionIds: CollectionId[],
userId: UserId,
_originalCipherView?: CipherView,
): Promise<Cipher> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sdkCipherView = cipherView.toSdkCipherView();
// SDK handles cipher history adjustment internally, so we pass null for original_cipher
const result = await ref.value
.vault()
.ciphers()
.share_cipher(
sdkCipherView,
asUuid(organizationId),
collectionIds.map((id) => asUuid(id)),
null,
);
return Cipher.fromSdkCipher(result);
}),
catchError((error: unknown) => {
this.logService.error(`Failed to share cipher: ${error}`);
throw error;
}),
),
);
}
async shareManyWithServer(
cipherViews: CipherView[],
organizationId: OrganizationId,
collectionIds: CollectionId[],
userId: UserId,
): Promise<Cipher[]> {
return await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
switchMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sdkCipherViews = cipherViews.map((cv) => cv.toSdkCipherView());
const results = await ref.value
.vault()
.ciphers()
.share_ciphers_bulk(
sdkCipherViews,
asUuid(organizationId),
collectionIds.map((id) => asUuid(id)),
);
return results.map((c) => Cipher.fromSdkCipher(c));
}),
catchError((error: unknown) => {
this.logService.error(`Failed to share multiple ciphers: ${error}`);
throw error;
}),
),
);
}
}

View File

@@ -117,8 +117,9 @@ describe("Cipher Service", () => {
let cipherService: CipherService;
let encryptionContext: EncryptionContext;
// BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation
// BehaviorSubjects for SDK feature flags - allows tests to change the value after service instantiation
let sdkCrudFeatureFlag$: BehaviorSubject<boolean>;
let sdkShareFeatureFlag$: BehaviorSubject<boolean>;
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
@@ -134,9 +135,15 @@ describe("Cipher Service", () => {
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Create BehaviorSubject for SDK feature flag - tests can update this to change behavior
// Create BehaviorSubjects for SDK feature flags - tests can update these to change behavior
sdkCrudFeatureFlag$ = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable());
sdkShareFeatureFlag$ = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM28190_SdkCipherShareOperations) {
return sdkShareFeatureFlag$.asObservable();
}
return sdkCrudFeatureFlag$.asObservable();
});
cipherService = new CipherService(
keyService,
@@ -889,6 +896,124 @@ describe("Cipher Service", () => {
userId,
);
});
it("should delegate to cipherSdkService when SDK share feature flag is enabled", async () => {
sdkShareFeatureFlag$.next(true);
const expectedCipher = new Cipher(cipherData);
expectedCipher.organizationId = orgId;
const cipherView = new CipherView(expectedCipher);
cipherView.organizationId = null; // Ensure organizationId is null for this test
const collectionIds = ["collection1", "collection2"] as CollectionId[];
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "shareWithServer")
.mockResolvedValue(expectedCipher);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
const result = await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);
expect(sdkServiceSpy).toHaveBeenCalledWith(
cipherView,
orgId,
collectionIds,
userId,
undefined,
);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
expect(result).toEqual(expectedCipher);
});
it("should pass originalCipherView to cipherSdkService when SDK share feature flag is enabled", async () => {
sdkShareFeatureFlag$.next(true);
const expectedCipher = new Cipher(cipherData);
const cipherView = new CipherView(expectedCipher);
cipherView.organizationId = null;
const originalCipherView = new CipherView(expectedCipher);
originalCipherView.name = "Original Cipher";
const collectionIds = ["collection1"] as CollectionId[];
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "shareWithServer")
.mockResolvedValue(expectedCipher);
await cipherService.shareWithServer(
cipherView,
orgId,
collectionIds,
userId,
originalCipherView,
);
expect(sdkServiceSpy).toHaveBeenCalledWith(
cipherView,
orgId,
collectionIds,
userId,
originalCipherView,
);
});
it("should throw when cipher already has organization and SDK share flag is enabled", async () => {
sdkShareFeatureFlag$.next(true);
const expectedCipher = new Cipher(cipherData);
expectedCipher.organizationId = orgId;
const cipherView = new CipherView(expectedCipher);
cipherView.organizationId = orgId; // Cipher already has organization
const collectionIds = ["collection1"] as CollectionId[];
await expect(
cipherService.shareWithServer(cipherView, orgId, collectionIds, userId),
).rejects.toThrow("Cipher is already associated with an organization.");
});
});
describe("shareManyWithServer()", () => {
it("should delegate to cipherSdkService when SDK share feature flag is enabled", async () => {
sdkShareFeatureFlag$.next(true);
const cipherView1 = new CipherView(new Cipher(cipherData));
cipherView1.organizationId = null;
const cipherView2 = new CipherView(new Cipher(cipherData));
cipherView2.organizationId = null;
const collectionIds = ["collection1"] as CollectionId[];
const sdkServiceSpy = jest
.spyOn(cipherSdkService, "shareManyWithServer")
.mockResolvedValue([]);
const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");
await cipherService.shareManyWithServer(
[cipherView1, cipherView2],
orgId,
collectionIds,
userId,
);
expect(sdkServiceSpy).toHaveBeenCalledWith(
[cipherView1, cipherView2],
orgId,
collectionIds,
userId,
);
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
});
it("should throw when any cipher already has organization and SDK share flag is enabled", async () => {
sdkShareFeatureFlag$.next(true);
const cipherView1 = new CipherView(new Cipher(cipherData));
cipherView1.organizationId = null;
const cipherView2 = new CipherView(new Cipher(cipherData));
cipherView2.organizationId = orgId; // Second cipher already has organization
const collectionIds = ["collection1"] as CollectionId[];
await expect(
cipherService.shareManyWithServer([cipherView1, cipherView2], orgId, collectionIds, userId),
).rejects.toThrow("Cipher is already associated with an organization.");
});
});
describe("decryptCiphers", () => {

View File

@@ -113,6 +113,10 @@ export class CipherService implements CipherServiceAbstraction {
FeatureFlag.PM27632_SdkCipherCrudOperations,
);
private readonly sdkCipherShareEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
FeatureFlag.PM28190_SdkCipherShareOperations,
);
constructor(
private keyService: KeyService,
private domainSettingsService: DomainSettingsService,
@@ -1086,12 +1090,31 @@ export class CipherService implements CipherServiceAbstraction {
organizationId: string,
collectionIds: string[],
userId: UserId,
originalCipher?: Cipher,
originalCipherView?: CipherView,
): Promise<Cipher> {
const useSdkShare = await firstValueFrom(this.sdkCipherShareEnabled$);
if (useSdkShare) {
return this.shareWithServerUsingSdk(
cipher,
organizationId,
collectionIds,
userId,
originalCipherView,
);
}
const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM22136_SdkCipherEncryption,
);
// Get original cipher for adjustCipherHistory
let originalCipher: Cipher | undefined;
if (originalCipherView) {
// Encrypt the provided originalCipherView
const encryptResult = await this.cipherEncryptionService.encrypt(originalCipherView, userId);
originalCipher = encryptResult?.cipher;
}
// If originalCipher is undefined, adjustCipherHistory will fetch from cache
await this.adjustCipherHistory(cipher, userId, originalCipher);
let encCipher: EncryptionContext;
@@ -1146,6 +1169,11 @@ export class CipherService implements CipherServiceAbstraction {
collectionIds: string[],
userId: UserId,
) {
const useSdkShare = await firstValueFrom(this.sdkCipherShareEnabled$);
if (useSdkShare) {
return this.shareManyWithServerUsingSdk(ciphers, organizationId, collectionIds, userId);
}
const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM22136_SdkCipherEncryption,
);
@@ -1199,6 +1227,51 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async shareWithServerUsingSdk(
cipher: CipherView,
organizationId: string,
collectionIds: string[],
userId: UserId,
originalCipherView?: CipherView,
): Promise<Cipher> {
if (cipher.organizationId != null) {
throw new Error("Cipher is already associated with an organization.");
}
const result = await this.cipherSdkService.shareWithServer(
cipher,
organizationId as OrganizationId,
collectionIds as CollectionId[],
userId,
originalCipherView,
);
await this.clearCache(userId);
return result;
}
private async shareManyWithServerUsingSdk(
ciphers: CipherView[],
organizationId: string,
collectionIds: string[],
userId: UserId,
): Promise<void> {
for (const cipher of ciphers) {
if (cipher.organizationId != null) {
throw new Error("Cipher is already associated with an organization.");
}
}
await this.cipherSdkService.shareManyWithServer(
ciphers,
organizationId as OrganizationId,
collectionIds as CollectionId[],
userId,
);
await this.clearCache(userId);
}
saveAttachmentWithServer(
cipher: Cipher,
unencryptedFile: any,

View File

@@ -61,7 +61,7 @@ export class DefaultCipherFormService implements CipherFormService {
organizationId,
cipher.collectionIds,
activeUserId,
config.originalCipher,
originalCipherView,
);
// If the collectionIds are the same, update the cipher normally
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {