Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f426fba
Add create and edit admin operations
nikwithak Nov 13, 2025
3756f38
Add delete operations
nikwithak Nov 13, 2025
99d1685
Add soft delete (PUT delete) operations
nikwithak Nov 13, 2025
0cd1aee
Add update_collection call for ciphers
nikwithak Nov 14, 2025
9f82e22
Add get_ciphers_for_org to CiphersClient
nikwithak Nov 14, 2025
f89f43d
Add admin endpoints for update and restore
nikwithak Nov 15, 2025
fe95edd
Add CipherError::Api(ApiError) variant
nikwithak Nov 19, 2025
bd13498
Clean up error handling
nikwithak Nov 19, 2025
c173c98
index on vault/pm-25821/cipher-admin-ops: bd134987 Clean up error hanโ€ฆ
nikwithak Nov 21, 2025
079cb89
Consolidate delete & restore operations
nikwithak Nov 21, 2025
f9eaafa
Cleanup and logic consolidation
nikwithak Nov 21, 2025
4e48743
Update docs for CiphersClient::list_org_ciphers
nikwithak Nov 22, 2025
f3a06f6
Add separate delete_as_admin functions
nikwithak Nov 24, 2025
eb3291a
Add tests for new create methods
nikwithak Nov 24, 2025
74714e6
Add tests for edit admin cipher endpoints
nikwithak Nov 25, 2025
3330a78
Add tests for delete cipher endpoints
nikwithak Nov 25, 2025
8307f07
Update repository when a cipher is soft-deleted
nikwithak Nov 25, 2025
6485b2e
Update tests for delete
nikwithak Nov 26, 2025
28880f9
Move restore operations to separate file
nikwithak Nov 26, 2025
0356b2d
Fix test_restore_many tests
nikwithak Nov 26, 2025
2246af5
Fix soft_delete_as_admin test
nikwithak Nov 26, 2025
0a558c1
Housekeeping - remove comments & warnings
nikwithak Nov 26, 2025
7dc66a5
Fix edit_as_admin tests
nikwithak Nov 26, 2025
28aac12
Merge branch 'main' of https://github.com/bitwarden/sdk-internal intoโ€ฆ
nikwithak Nov 26, 2025
a0ba6e3
Fix clippy warnings
nikwithak Nov 26, 2025
f90129b
Add soft-delete funciton to Cipher
nikwithak Dec 3, 2025
8c633b9
Move cipher admin functions to separate client
nikwithak Dec 3, 2025
3e38626
Move delete logic to isolated functions, outside of CiphersClient
nikwithak Dec 4, 2025
7467c01
Move restore functions to isolated functions, remove wiremock use
nikwithak Dec 5, 2025
6d59590
Move admin delete ops to new CipherAdminClient
nikwithak Dec 5, 2025
ef3fef3
Move restore operations to CipherAdminClient
nikwithak Dec 5, 2025
715f75c
Move create admin operations to CreateAdminClient
nikwithak Dec 5, 2025
feb335f
Improve docs on delete.rs
nikwithak Dec 5, 2025
1f6c8ab
Move admin edit operations to CipherAdminController
nikwithak Dec 5, 2025
a1c61ec
Fix cipher admin create tests
nikwithak Dec 5, 2025
f029f56
Fix edit cipher admin tests
nikwithak Dec 5, 2025
8cbf8c8
Remove helper function for get_api_configurations in CiphersClient
nikwithak Dec 5, 2025
8aa3692
Move list_org_ciphers operation to admin client
nikwithak Dec 5, 2025
16e28b0
Housekeeping: Remove commented code, change ::into -> ::from
nikwithak Dec 5, 2025
ca6ae3f
Merge branch 'main' of https://github.com/bitwarden/sdk-internal intoโ€ฆ
nikwithak Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 149 additions & 11 deletions crates/bitwarden-vault/src/cipher/cipher.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
use bitwarden_api_api::{
apis::ciphers_api::{PutShareError, PutShareManyError},
models::{
CipherDetailsResponseModel, CipherRequestModel, CipherResponseModel,
CipherWithIdRequestModel,
},
use bitwarden_api_api::models::{
CipherDetailsResponseModel, CipherMiniDetailsResponseModel, CipherMiniResponseModel,
CipherRequestModel, CipherResponseModel, CipherWithIdRequestModel,
};
use bitwarden_collections::collection::CollectionId;
use bitwarden_core::{
MissingFieldError, OrganizationId, UserId,
ApiError, MissingFieldError, OrganizationId, UserId,
key_management::{KeyIds, MINIMUM_ENFORCE_ICON_URI_HASH_VERSION, SymmetricKeyId},
require,
};
Expand Down Expand Up @@ -63,15 +60,19 @@ pub enum CipherError {
#[error("This cipher cannot be moved to the specified organization")]
OrganizationAlreadySet,
#[error(transparent)]
PutShare(#[from] bitwarden_api_api::apis::Error<PutShareError>),
#[error(transparent)]
PutShareMany(#[from] bitwarden_api_api::apis::Error<PutShareManyError>),
#[error(transparent)]
Repository(#[from] RepositoryError),
#[error(transparent)]
Chrono(#[from] chrono::ParseError),
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
#[error(transparent)]
Api(#[from] ApiError),
Comment on lines +68 to +69
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.

}

impl<T> From<bitwarden_api_api::apis::Error<T>> for CipherError {
fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
Self::Api(value.into())
}
}

/// Helper trait for operations on cipher types.
Expand Down Expand Up @@ -636,6 +637,11 @@ impl Cipher {
}
Ok(())
}

pub(crate) fn soft_delete(&mut self) {
self.deleted_date = Some(Utc::now());
self.archived_date = None;
}
}
impl CipherView {
#[allow(missing_docs)]
Expand Down Expand Up @@ -1061,6 +1067,138 @@ impl TryFrom<CipherResponseModel> for Cipher {
}
}

impl TryFrom<CipherMiniResponseModel> for Cipher {
type Error = VaultParseError;
fn try_from(cipher_mini: CipherMiniResponseModel) -> Result<Self, Self::Error> {
Ok(Cipher {
id: cipher_mini.id.map(CipherId::new),
organization_id: cipher_mini.organization_id.map(OrganizationId::new),
key: EncString::try_from_optional(cipher_mini.key)?,
name: require!(EncString::try_from_optional(cipher_mini.name)?),
notes: EncString::try_from_optional(cipher_mini.notes)?,
r#type: require!(cipher_mini.r#type).into(),
login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?,
identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?,
card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?,
secure_note: cipher_mini
.secure_note
.map(|s| (*s).try_into())
.transpose()?,
ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?,
reprompt: cipher_mini
.reprompt
.map(|r| r.into())
.unwrap_or(CipherRepromptType::None),
organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true),
attachments: cipher_mini
.attachments
.map(|a| a.into_iter().map(|a| a.try_into()).collect())
.transpose()?,
fields: cipher_mini
.fields
.map(|f| f.into_iter().map(|f| f.try_into()).collect())
.transpose()?,
password_history: cipher_mini
.password_history
.map(|p| p.into_iter().map(|p| p.try_into()).collect())
.transpose()?,
creation_date: require!(cipher_mini.creation_date)
.parse()
.map_err(Into::<VaultParseError>::into)?,
deleted_date: cipher_mini
.deleted_date
.map(|d| d.parse())
.transpose()
.map_err(Into::<VaultParseError>::into)?,
revision_date: require!(cipher_mini.revision_date)
.parse()
.map_err(Into::<VaultParseError>::into)?,
archived_date: cipher_mini
.archived_date
.map(|d| d.parse())
.transpose()
.map_err(Into::<VaultParseError>::into)?,
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(),
Comment on lines +1121 to +1127
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.

data: None,
})
}
}

impl TryFrom<CipherMiniDetailsResponseModel> for Cipher {
type Error = VaultParseError;

fn try_from(cipher_mini: CipherMiniDetailsResponseModel) -> Result<Self, Self::Error> {
Ok(Cipher {
id: cipher_mini.id.map(CipherId::new),
organization_id: cipher_mini.organization_id.map(OrganizationId::new),
key: EncString::try_from_optional(cipher_mini.key)?,
name: require!(EncString::try_from_optional(cipher_mini.name)?),
notes: EncString::try_from_optional(cipher_mini.notes)?,
r#type: require!(cipher_mini.r#type).into(),
login: cipher_mini.login.map(|l| (*l).try_into()).transpose()?,
identity: cipher_mini.identity.map(|i| (*i).try_into()).transpose()?,
card: cipher_mini.card.map(|c| (*c).try_into()).transpose()?,
secure_note: cipher_mini
.secure_note
.map(|s| (*s).try_into())
.transpose()?,
ssh_key: cipher_mini.ssh_key.map(|s| (*s).try_into()).transpose()?,
reprompt: cipher_mini
.reprompt
.map(|r| r.into())
.unwrap_or(CipherRepromptType::None),
organization_use_totp: cipher_mini.organization_use_totp.unwrap_or(true),
attachments: cipher_mini
.attachments
.map(|a| a.into_iter().map(|a| a.try_into()).collect())
.transpose()?,
fields: cipher_mini
.fields
.map(|f| f.into_iter().map(|f| f.try_into()).collect())
.transpose()?,
password_history: cipher_mini
.password_history
.map(|p| p.into_iter().map(|p| p.try_into()).collect())
.transpose()?,
creation_date: require!(cipher_mini.creation_date)
.parse()
.map_err(Into::<VaultParseError>::into)?,
deleted_date: cipher_mini
.deleted_date
.map(|d| d.parse())
.transpose()
.map_err(Into::<VaultParseError>::into)?,
revision_date: require!(cipher_mini.revision_date)
.parse()
.map_err(Into::<VaultParseError>::into)?,
archived_date: cipher_mini
.archived_date
.map(|d| d.parse())
.transpose()
.map_err(Into::<VaultParseError>::into)?,
collection_ids: cipher_mini
.collection_ids
.into_iter()
.flatten()
.map(CollectionId::new)
.collect(),
data: None,
folder_id: Default::default(),
favorite: Default::default(),
edit: Default::default(),
permissions: Default::default(),
view_password: Default::default(),
local_data: Default::default(),
})
}
}

#[cfg(test)]
mod tests {

Expand Down
193 changes: 193 additions & 0 deletions crates/bitwarden-vault/src/cipher/cipher_client/admin/create.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
use bitwarden_api_api::models::CipherCreateRequestModel;
use bitwarden_collections::collection::CollectionId;
use bitwarden_core::{
ApiError, MissingFieldError, NotAuthenticatedError, UserId, key_management::KeyIds,
};
use bitwarden_crypto::{CryptoError, IdentifyKey, KeyStore};
use bitwarden_error::bitwarden_error;
use thiserror::Error;
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;

use crate::{
Cipher, CipherView, VaultParseError,
cipher_client::{
admin::CipherAdminClient,
create::{CipherCreateRequest, CipherCreateRequestInternal, CreateCipherError},
},
};

#[allow(missing_docs)]
#[bitwarden_error(flat)]
#[derive(Debug, Error)]
pub enum CreateCipherAdminError {
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error(transparent)]
Api(#[from] ApiError),
#[error(transparent)]
VaultParse(#[from] VaultParseError),
#[error(transparent)]
MissingField(#[from] MissingFieldError),
#[error(transparent)]
NotAuthenticated(#[from] NotAuthenticatedError),
}

impl<T> From<bitwarden_api_api::apis::Error<T>> for CreateCipherAdminError {
fn from(val: bitwarden_api_api::apis::Error<T>) -> Self {
Self::Api(val.into())
}
}

/// Wraps the API call to create a cipher using the admin endpoint, for easier testing.
async fn create_cipher(
request: CipherCreateRequestInternal,
collection_ids: Vec<CollectionId>,
encrypted_for: UserId,
api_client: &bitwarden_api_api::apis::ApiClient,
key_store: &KeyStore<KeyIds>,
) -> Result<CipherView, CreateCipherError> {
let mut cipher_request = key_store.encrypt(request)?;
cipher_request.encrypted_for = Some(encrypted_for.into());

let cipher: 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()?;

Ok(key_store.decrypt(&cipher)?)
}

#[cfg_attr(feature = "wasm", wasm_bindgen)]
impl CipherAdminClient {
/// Creates a new [Cipher] for an organization, using the admin server endpoints endpoints.
/// Creates the Cipher on the server only, does not store it to local state.
pub async fn create(
&self,
request: CipherCreateRequest,
collection_ids: Vec<CollectionId>,
) -> Result<CipherView, CreateCipherError> {
let key_store = self.client.internal.get_key_store();
let config = self.client.internal.get_api_configurations().await;
let mut internal_request: CipherCreateRequestInternal = request.into();

let user_id = self
.client
.internal
.get_user_id()
.ok_or(NotAuthenticatedError)?;

// TODO: Once this flag is removed, the key generation logic should
// be moved closer to the actual encryption logic.
if self
.client
.internal
.get_flags()
.enable_cipher_key_encryption
{
let key = internal_request.key_identifier();
internal_request.generate_cipher_key(&mut key_store.context(), key)?;
}

create_cipher(
internal_request,
collection_ids,
user_id,
&config.api_client,
key_store,
)
.await
}
}

#[cfg(test)]
mod tests {
use bitwarden_api_api::models::CipherMiniResponseModel;
use bitwarden_core::{OrganizationId, key_management::SymmetricKeyId};
use bitwarden_crypto::SymmetricCryptoKey;
use chrono::Utc;

use super::*;
use crate::{CipherRepromptType, CipherViewType, LoginView};

const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
const TEST_COLLECTION_ID: &str = "73546b86-8802-4449-ad2a-69ea981b4ffd";
const TEST_USER_ID: &str = "550e8400-e29b-41d4-a716-446655440000";
const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8";

#[tokio::test]
async fn test_create_org_cipher() {
let api_client = bitwarden_api_api::apis::ApiClient::new_mocked(|mock| {
mock.ciphers_api
.expect_post_admin()
.returning(move |request| {
let request = request.unwrap();

Ok(CipherMiniResponseModel {
id: Some(TEST_CIPHER_ID.try_into().unwrap()),
organization_id: request
.cipher
.organization_id
.and_then(|id| id.parse().ok()),
name: Some(request.cipher.name.clone()),
r#type: request.cipher.r#type,
creation_date: Some(Utc::now().to_string()),
revision_date: Some(Utc::now().to_string()),
..Default::default()
})
});
});

let store: KeyStore<KeyIds> = KeyStore::default();
#[allow(deprecated)]
let _ = store.context_mut().set_symmetric_key(
SymmetricKeyId::User,
SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
);
#[allow(deprecated)]
let _ = store.context_mut().set_symmetric_key(
SymmetricKeyId::Organization(TEST_ORG_ID.parse::<OrganizationId>().unwrap().into()),
SymmetricCryptoKey::make_aes256_cbc_hmac_key(),
);

let cipher_request: CipherCreateRequestInternal = CipherCreateRequest {
organization_id: Some(TEST_ORG_ID.parse().unwrap()),
folder_id: None,
name: "Test Cipher".into(),
notes: None,
favorite: false,
reprompt: CipherRepromptType::None,
r#type: CipherViewType::Login(LoginView {
username: None,
password: None,
password_revision_date: None,
uris: None,
totp: None,
autofill_on_page_load: None,
fido2_credentials: None,
}),
fields: vec![],
}
.into();

let response = create_cipher(
cipher_request.clone(),
vec![TEST_COLLECTION_ID.parse().unwrap()],
TEST_USER_ID.parse().unwrap(),
&api_client,
&store,
)
.await
.unwrap();

assert_eq!(response.id, Some(TEST_CIPHER_ID.parse().unwrap()));
assert_eq!(
response.organization_id,
cipher_request.create_request.organization_id
);
}
}
Loading
Loading