Skip to content

Conversation

@nikwithak
Copy link
Contributor

@nikwithak nikwithak commented Nov 15, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-25821

📔 Objective

Migrates the logic for orchestrating API calls for several operations related to the Admin API functionality. Adds the following operations to CiphersClient, which call the appropriate API endpoints:

  • delete
  • delete_many
  • delete_as_admin
  • delete_many_as_admin
  • soft_delete
  • soft_delete_many
  • soft_delete_as_admin
  • soft_delete_many_as_admin
  • restore
  • restore_many
  • restore_as_admin
  • restore_many_as_admin
  • edit_as_admin
  • list_org_ciphers
  • create_as_admin

PR Notes: Sorry for the heft - The line count is high on this PR, but a majority of it is unit tests. This should have been split into multiple tickets, in hindsight.

Note also that this hasn't been tested directly with the client yet, and so additional changes may be needed if bugs are discovered when integrating into the clients (future tickets) - these operations are not currently used anywhere.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation
    team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed
    issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@github-actions
Copy link
Contributor

github-actions bot commented Nov 15, 2025

Logo
Checkmarx One – Scan Summary & Details8ae3fbb7-39e7-45ea-b7b8-62ef148c084f

Great job! No new security vulnerabilities introduced in this pull request

@github-actions
Copy link
Contributor

github-actions bot commented Nov 15, 2025

🔍 SDK Breaking Change Detection Results

SDK Version: vault/pm-25821/cipher-admin-ops (a0ba6e3)
Completed: 2025-11-26 22:40:41 UTC
Total Time: 245s

Client Status Details
typescript ✅ No breaking changes detected TypeScript compilation passed with new SDK version - View Details

Breaking change detection completed. View SDK workflow

@codecov
Copy link

codecov bot commented Nov 15, 2025

Codecov Report

❌ Patch coverage is 90.27378% with 135 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.18%. Comparing base (4b0d128) to head (a0ba6e3).

Files with missing lines Patch % Lines
crates/bitwarden-vault/src/cipher/cipher.rs 49.19% 63 Missing ⚠️
...s/bitwarden-vault/src/cipher/cipher_client/edit.rs 80.34% 34 Missing ⚠️
...es/bitwarden-vault/src/cipher/cipher_client/get.rs 0.00% 19 Missing ⚠️
...bitwarden-vault/src/cipher/cipher_client/create.rs 96.44% 9 Missing ⚠️
...bitwarden-vault/src/cipher/cipher_client/delete.rs 98.73% 5 Missing ⚠️
...itwarden-vault/src/cipher/cipher_client/restore.rs 98.81% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #560      +/-   ##
==========================================
+ Coverage   79.46%   80.18%   +0.72%     
==========================================
  Files         302      304       +2     
  Lines       32331    33697    +1366     
==========================================
+ Hits        25691    27020    +1329     
- Misses       6640     6677      +37     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nikwithak nikwithak requested a review from Hinton November 26, 2025 22:01
@nikwithak nikwithak force-pushed the vault/pm-25821/cipher-admin-ops branch from aa8a96d to 7dc66a5 Compare November 26, 2025 22:15
@nikwithak nikwithak marked this pull request as ready for review November 26, 2025 22:15
@nikwithak nikwithak requested review from a team as code owners November 26, 2025 22:15
@claude
Copy link

claude bot commented Nov 26, 2025

Claude finished @nikwithak's task —— View job


Code Review Complete

I've reviewed this PR which migrates Cipher Admin operation API calls to the SDK. Here's my comprehensive feedback:


Summary

This PR successfully implements 15 new admin operation methods for CiphersClient, including delete, soft delete, restore, edit, create, and list operations with both standard and admin endpoint variants. The code is well-structured with comprehensive unit tests accounting for most of the 1674 line additions.


PR Title & Description Assessment

💭 Finding 1: The PR title and description are clear and appropriate, though the description could benefit from more detail about the test coverage strategy.

Suggested enhancement

Consider adding to the description:

  • Test coverage strategy (unit tests with mocked API calls)
  • Confirmation that integration testing will occur during client integration
  • Note about repository state management decisions (admin vs non-admin endpoints)

Critical Issues

Finding 2: Repository update inconsistency in delete operations (delete.rs:28-42)

The delete_as_admin method doesn't update the local repository after deleting, while the non-admin delete method does. This creates an inconsistency where admin operations leave stale data in local state.

// delete_as_admin - no repository update
pub async fn delete_as_admin(&self, cipher_id: CipherId) -> Result<(), ApiError> {
    let configs = self.get_api_configurations().await;
    let api = configs.api_client.ciphers_api();
    api.delete_admin(cipher_id.into()).await?;
    Ok(())  // ⚠️ Repository not updated
}

// delete - has repository update
pub async fn delete(&self, cipher_id: CipherId) -> Result<(), DeleteCipherError> {
    let configs = self.get_api_configurations().await;
    let api = configs.api_client.ciphers_api();
    api.delete(cipher_id.into()).await?;
    self.get_repository()?.remove(cipher_id.to_string()).await?;  // ✓ Repository updated
    Ok(())
}

Impact: Admin users may see deleted ciphers still appear in their local state until a full sync occurs.

Recommendation: Either:

  1. Update repository in admin endpoints consistently, OR
  2. Document this design decision and ensure consuming applications understand they must sync after admin operations

Finding 3: Inconsistent collection consumption patterns (delete.rs:54, 69, 139)

Methods use inconsistent approaches for consuming cipher_ids:

  • delete_many_as_admin: uses into_iter() (line 139)
  • delete_many: uses iter() then loops with owned values (line 70, 75)
  • soft_delete_many_as_admin: uses into_iter() (line 139)
// Inconsistency example:
// delete_many uses iter() + clone
ids: cipher_ids.iter().map(|id| id.to_string()).collect(),
// ...then later
for cipher_id in cipher_ids {  // This works but is confusing

// delete_many_as_admin uses into_iter()
ids: cipher_ids.into_iter().map(|id| id.to_string()).collect(),

Recommendation: Use into_iter() consistently when the vector is consumed, or use iter() with explicit cloning if values are needed multiple times. The current mix is semantically correct but confusing for maintainability.


⚠️ Finding 4: Missing error handling for repository operations in loops (delete.rs:75-77, restore.rs:79-85)

Repository update failures in batch operations don't roll back API changes, potentially causing state divergence:

for cipher_id in cipher_ids {
    self.get_repository()?.remove(cipher_id.to_string()).await?;  // ⚠️ Fails partway through
}

If the repository update fails on the 3rd of 5 ciphers, the first 2 are removed from local state but the last 3 remain, even though all 5 were deleted on the server.

Recommendation: Consider collecting results and handling partial failures explicitly, or document that repository sync should be used to recover from such scenarios.


Minor Issues & Improvements

⚠️ Finding 5: Missing documentation on edit_as_admin method (edit.rs:381-387)

The method has an explicit // TODO: add docs comment. Admin methods need documentation explaining the difference from non-admin variants, particularly regarding repository updates and permissions.

Recommendation: Document that admin endpoints:

  • Don't update local repository
  • Require organization admin permissions
  • May not respect user-level encryption in some scenarios

♻️ Finding 6: Duplicate test setup code across files (delete.rs:170-227, restore.rs:140-197, create.rs:388-445, edit.rs:531-588)

The create_client_with_wiremock function is duplicated with identical implementation across all test modules (156 lines each × 4 files = 624 lines of duplication).

Technical debt: This violates DRY principles and makes test maintenance harder. If the crypto initialization logic needs to change, it must be updated in 4 places.

Recommendation: Extract to a shared test utilities module, e.g., crates/bitwarden-vault/src/cipher/cipher_client/test_utils.rs or use the bitwarden_test crate's infrastructure.


🎨 Finding 7: Test organization inconsistency (restore.rs:239-505)

The restore tests have very comprehensive coverage including failure modes and edge cases, while other operation files have more basic coverage. This inconsistency makes it unclear what the coverage standard is.

Recommendation: Consider documenting test coverage expectations in contributing docs or add similar coverage levels to other operations.


🎨 Finding 8: Ambiguous method naming for batch operations

Methods like delete_many and delete_many_as_admin take an optional organization_id parameter but the name doesn't indicate this organizational scope:

pub async fn delete_many(
    &self,
    cipher_ids: Vec<CipherId>,
    organization_id: Option<OrganizationId>,  // Not obvious from method name
) -> Result<(), DeleteCipherError>

Recommendation: Consider method names like delete_many_with_org_scope or add documentation emphasizing when organization_id should be provided.


💭 Finding 9: Low test coverage reported by Codecov (5% patch coverage)

The PR has 342 lines without test coverage despite having extensive unit tests. This suggests the tests aren't running in CI or aren't being counted properly.

Investigation needed: The test code itself is comprehensive, so this might be:

  • Integration test endpoints not being executed in CI
  • Mocked API calls not triggering coverage tracking
  • Test compilation issues

Recommendation: Verify tests are actually running in CI and investigate coverage reporting configuration.


Security Considerations

No sensitive data logging detected: The code properly avoids logging cipher IDs, encrypted data, or other sensitive information in error paths.

Error types properly abstracted: Using ApiError and custom error enums prevents leaking internal implementation details.

💭 Finding 10: Consider rate limiting implications for batch operations

Methods like delete_many and soft_delete_many loop through repository operations without any rate limiting or backpressure handling:

for cipher_id in cipher_ids {
    self.get_repository()?.remove(cipher_id.to_string()).await?;
}

For large batches, this could cause performance issues or rate limiting. Consider whether batched repository operations would be more efficient.


Good Practices Observed

The following excellent practices were noted:

  • Comprehensive unit test coverage with wiremock for API testing
  • Proper error type conversions and propagation
  • Consistent use of CipherId type safety instead of raw strings
  • Soft delete implementation properly updates deleted_date and clears archived_date
  • Password history tracking in edit operations

Action Items for Author

  1. Critical: Clarify and document repository update policy for admin endpoints (Finding 2)
  2. Critical: Review and standardize collection consumption patterns (Finding 3)
  3. Important: Add error handling strategy documentation for batch operation failures (Finding 4)
  4. Important: Complete documentation for edit_as_admin method (Finding 5)
  5. Nice to have: Extract duplicate test helper code (Finding 6)
  6. Nice to have: Investigate low coverage reporting (Finding 9)

Testing Recommendations

The unit tests are well-written, but consider adding:

  • Tests for error conditions (network failures, permission errors)
  • Tests for very large batch operations
  • Tests verifying repository state after partial failures
  • Integration tests when this gets wired into clients

@Hinton
Copy link
Member

Hinton commented Nov 27, 2025

Be mindful that the autogenerated API bindings do not have any graceful handling of malformed data. I.e. an enum outside the valid range errors out. https://bitwarden.atlassian.net/browse/PM-6169 tracks improvements to this, and if this improvement is required reach out to platform to ensure it's prioritized.

CC @trmartin4

Copy link
Member

@Hinton Hinton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider moving all the admin commands into a separate client. While they share some common logic I think they fundamentally behave differently and it might be confusing for consumers to call an admin method and not have the state react?

CiphersClient & CiphersAdminClient

/// Generate a new key for the cipher, re-encrypting internal data, if necessary, and stores the
/// encrypted key to the cipher data.
fn generate_cipher_key(
pub(crate) fn generate_cipher_key(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is this change needed? It doesn't look like it's used outside this module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I had re-used this for a separate admin file, but I ended up adding it to the same file. I thought I already removed this, will fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this in (changed to pub(super) so it can be re-used by the CipherAdminClient since it shares the request types.

repository: &R,
encrypted_for: UserId,
request: CipherCreateRequestInternal,
collection_ids: Vec<CollectionId>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: CipherCreateRequest has organization_id, is there a reason organization is part of the cipher create request while collections are part of the method? Collections are tightly coupled to an organization so this split does not make much sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This object ends up mapping directly to the CreateCipherRequestModel, which is re-used across the different endpoints to create a cipher (POST /cipher, POST /cipher/create, POST /cipher/admin), even though POST /cipher does not take collection_ids. I will play around with ways to re-structure these in a way that fits what you're describing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just leave it for now, but would be nice to clean it up in the future.

Comment on lines 235 to 266
if as_admin && cipher_request.organization_id.is_some() {
cipher = api_client
.ciphers_api()
.post_admin(Some(CipherCreateRequestModel {
collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()),
cipher: Box::new(cipher_request),
}))
.await?
.try_into()?;
} else if !collection_ids.is_empty() {
cipher = api_client
.ciphers_api()
.post_create(Some(CipherCreateRequestModel {
collection_ids: Some(collection_ids.into_iter().map(Into::into).collect()),
cipher: Box::new(cipher_request),
}))
.await
.map_err(ApiError::from)?
.try_into()?;
repository
.set(require!(cipher.id).to_string(), cipher.clone())
.await?;
} else {
cipher = api_client
.ciphers_api()
.post(Some(cipher_request))
.await
.map_err(ApiError::from)?
.try_into()?;
repository
.set(require!(cipher.id).to_string(), cipher.clone())
.await?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What's going on here? It doesn't really make much sense to have three different endpoints for creating ciphers.

  • Is ciphers/create not identical to doing post on ciphers?
  • From a REST perspective having a dedicated admin endpoint is also quite weird.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three endpoints is because of the way the backend is structured today.

  • POST /ciphers is only used for creating an individual cipher. Even though it accepts an organization_id in the request, it does not have anywhere to add collection_ids.
  • POST /ciphers/create is how we create an org cipher, as it accepts the collection_ids.
  • The admin endpoint has its own uses that do a separate set of permission checks than the standard endpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this so that it only calls two endpoints (POST /ciphers or POST /ciphers/create), adn moved the third (/ciphers/admin) to a separate file / client.

Comment on lines 315 to 330
/// Creates a new [Cipher] and saves it to the server.
pub async fn create(
&self,
request: CipherCreateRequest,
) -> Result<CipherView, CreateCipherError> {
self.create_cipher(request, vec![], false).await
}

/// Creates a new [Cipher] for an organization, and saves it to the server.
pub async fn create_org_cipher(
&self,
request: CipherCreateRequest,
collection_ids: Vec<CollectionId>,
) -> Result<CipherView, CreateCipherError> {
self.create_cipher(request, collection_ids, false).await
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract here is weird. CipherCreteRequest allows you to specify an organization id. That implies you can make orgnaizational items.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree here - the CipherCreateRequest was built to match the valid fields on the API request model, but when we create an org cipher, the client calls the separate (POST /ciphers/create) endpoint, which takes the collection_Ids. The standard POST /ciphers endpoint does not have collection_ids in the response model.

I'm thinking of modifying the CipherCreateRequest to have both org_id and collection_ids, and writing the logic to call the POST /ciphers/create endpoint iff they are both present on the request; LMK your thoughts?

Comment on lines +354 to +357
use wiremock::{
Mock, ResponseTemplate,
matchers::{method, path},
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: We've stopped using wiremock for api mocking. You can look at crates/bitwarden-vault/src/folder/edit.rs for up to date exampels of using ApiClient::new_mocked.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated - now uses standalone methods, and updated unit tests to test those standalones using ApiClient::new_mocked with passed-in dependencies.


impl CipherEditRequest {
fn generate_cipher_key(
pub(crate) fn generate_cipher_key(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this doesn't seem used outside of this module.

Comment on lines +49 to +50
#[error(transparent)]
Decrypt(#[from] DecryptError),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What operation requires decrypt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the function input for these requests is plaintext, and the SDK handles encryption before sending to the server, we return the updated object decrypted as well. example

We return a CryptoError in other instances (e.g. the edit_cipher implementation here), but this call returns a DecryptError. I will look into casting it approriately to use the same error.

Tangentially: Do you think it makes sense to use this approach - returning the decrypted CipherView object to the consumer, rather than the encrypted Cipher (all of it locally only)

Comment on lines 379 to 381
// putCipherAdmin(id, request: CipherRequest)
// ciphers_id_admin_put
#[allow(missing_docs)] // TODO: add docs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Add documentation.

Comment on lines +68 to +69
#[error(transparent)]
Api(#[from] ApiError),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The methods putting Api errors in CipherError should really be updated to not use cipher error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only one still using CipherError is the share operations, which calls existing functions that currently returns CipherError already (e.g. https://github.com/bitwarden/sdk-internal/blob/vault/pm-25821/cipher-admin-ops/crates/bitwarden-vault/src/cipher/cipher_client/share_cipher.rs#L180-L184) - I think we can migrate this one to its own error type in the future.

Comment on lines +1116 to +1122
edit: Default::default(),
favorite: Default::default(),
folder_id: Default::default(),
permissions: Default::default(),
view_password: Default::default(),
local_data: Default::default(),
collection_ids: Default::default(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reflection: Putting default values here can be dangerous, because there is nothing to indicate the Cipher model is incomplete.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately several API operations don't return the full cipher, only the CipherMiniResponseModel - for the Admin operations, since we don't update the repository, the intent is to still return a consistent type the user can handle, if needed - the concern is valid though. I think we can:

  1. Merge it with the known existing Cipher data (if it's available),
  2. Continue with this approach, knowing it's primarily for the admin operations which don't update local state - if this route, I can move the logic to be private to the admin operations, rather than a blanket From implementation,
  3. Return the CipherMiniResponseModel as-is, or create a MiniCipherView type for mapping / returning,which doesn't expose these fields at all.

Do you have a preference? Feel free to ping me on Slack when you're free for a deeper discussion - there are a handful of API operations that currently only return a subset of data like this.

@nikwithak nikwithak requested a review from Hinton December 5, 2025 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants