From 89af9be7bbc9296723b8ca2b724f1e3e3c329179 Mon Sep 17 00:00:00 2001 From: maxkpower Date: Tue, 17 Mar 2026 19:28:28 +0100 Subject: [PATCH] prototype for sm manage permissions --- .../src/access_policies/conversions.rs | 63 ++++++++ .../src/access_policies/get_granted.rs | 79 ++++++++++ .../src/access_policies/get_project.rs | 82 ++++++++++ .../src/access_policies/get_secret.rs | 75 +++++++++ .../bitwarden-sm/src/access_policies/mod.rs | 31 ++++ .../src/access_policies/potential_grantees.rs | 128 ++++++++++++++++ .../src/access_policies/put_granted.rs | 78 ++++++++++ .../src/access_policies/put_project.rs | 127 +++++++++++++++ .../src/access_policies/put_secret.rs | 46 ++++++ .../bitwarden-sm/src/access_policies/types.rs | 144 ++++++++++++++++++ bitwarden_license/bitwarden-sm/src/client.rs | 7 +- .../src/client_access_policies.rs | 83 ++++++++++ bitwarden_license/bitwarden-sm/src/lib.rs | 9 +- crates/bitwarden-api-api/src/lib.rs | 49 ++++++ .../src/models/access_policy_request.rs | 10 +- .../models/granted_access_policy_request.rs | 10 +- ...ed_project_access_policy_response_model.rs | 3 + .../group_access_policy_response_model.rs | 3 + ...ce_account_access_policy_response_model.rs | 3 + .../user_access_policy_response_model.rs | 3 + package.json | 1 + 21 files changed, 1029 insertions(+), 5 deletions(-) create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/conversions.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/get_granted.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/get_project.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/get_secret.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/mod.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/potential_grantees.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/put_granted.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/put_project.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/put_secret.rs create mode 100644 bitwarden_license/bitwarden-sm/src/access_policies/types.rs create mode 100644 bitwarden_license/bitwarden-sm/src/client_access_policies.rs diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/conversions.rs b/bitwarden_license/bitwarden-sm/src/access_policies/conversions.rs new file mode 100644 index 0000000000..8618ae1c27 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/conversions.rs @@ -0,0 +1,63 @@ +use bitwarden_api_api::models::{ + GroupAccessPolicyResponseModel, ServiceAccountAccessPolicyResponseModel, + UserAccessPolicyResponseModel, +}; +use bitwarden_core::key_management::{KeyIds, SymmetricKeyId}; +use bitwarden_crypto::{Decryptable, EncString, KeyStoreContext}; + +use super::types::{ + AccessPolicyResponse, GroupAccessPolicyResponse, ServiceAccountAccessPolicyResponse, + UserAccessPolicyResponse, +}; + +pub(super) fn user_from_api(p: UserAccessPolicyResponseModel) -> Option { + Some(UserAccessPolicyResponse { + organization_user_id: p.organization_user_id?, + organization_user_name: p.organization_user_name, + current_user: p.current_user.unwrap_or(false), + policy: api_permissions(p.read, p.write, p.manage)?, + }) +} + +pub(super) fn group_from_api( + p: GroupAccessPolicyResponseModel, +) -> Option { + Some(GroupAccessPolicyResponse { + group_id: p.group_id?, + group_name: p.group_name, + current_user_in_group: p.current_user_in_group.unwrap_or(false), + policy: api_permissions(p.read, p.write, p.manage)?, + }) +} + +pub(super) fn service_account_from_api( + p: ServiceAccountAccessPolicyResponseModel, + ctx: &mut KeyStoreContext, + org_key: SymmetricKeyId, +) -> Option { + let decrypted_name = p + .service_account_name + .and_then(|n| n.parse::().ok()?.decrypt(ctx, org_key).ok()); + Some(ServiceAccountAccessPolicyResponse { + service_account_id: p.service_account_id?, + service_account_name: decrypted_name, + policy: api_permissions(p.read, p.write, p.manage)?, + }) +} + +/// Returns `None` if `manage` is absent from the API response. +/// +/// `manage` must not default to `false` — an absent field would silently downgrade a policy +/// that has `manage: true` in the database. Instead we drop the policy from the list so the +/// caller can detect the gap (e.g. a missing field from an older server version). +fn api_permissions( + read: Option, + write: Option, + manage: Option, +) -> Option { + Some(AccessPolicyResponse { + read: read.unwrap_or(false), + write: write.unwrap_or(false), + manage: manage?, + }) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/get_granted.rs b/bitwarden_license/bitwarden-sm/src/access_policies/get_granted.rs new file mode 100644 index 0000000000..b9458b7bb0 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/get_granted.rs @@ -0,0 +1,79 @@ +use bitwarden_core::{client::Client, key_management::SymmetricKeyId}; +use bitwarden_crypto::{Decryptable, EncString}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::types::{ + AccessPolicyResponse, GrantedPoliciesResponse, GrantedProjectPolicyResponse, +}; + +#[derive(Error, Debug)] +pub enum GetGrantedPoliciesError { + #[error("Internal error: {0}")] + InternalError(String), + #[error("Crypto error: {0}")] + CryptoError(String), +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetGrantedPoliciesRequest { + pub service_account_id: Uuid, +} + +pub async fn get_granted_policies( + client: &Client, + request: &GetGrantedPoliciesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let response = config + .api_client + .access_policies_api() + .get_service_account_granted_policies(request.service_account_id) + .await + .map_err(|e| GetGrantedPoliciesError::InternalError(format!("{e:?}")))?; + + let org_id = client + .internal + .get_access_token_organization() + .ok_or_else(|| { + GetGrantedPoliciesError::CryptoError("Not authenticated as a service account".into()) + })?; + let key_store = client.internal.get_key_store(); + let mut ctx = key_store.context(); + let org_key = SymmetricKeyId::Organization(org_id); + + let granted_project_policies = response + .granted_project_policies + .unwrap_or_default() + .into_iter() + .filter_map(|details| { + let policy_model = details.access_policy?; + let project_id = policy_model.granted_project_id?; + let decrypted_name = policy_model + .granted_project_name + .and_then(|n| n.parse::().ok()?.decrypt(&mut ctx, org_key).ok()); + // manage must not default to false — an absent field would silently downgrade a + // policy that has manage:true in the database. Drop the policy instead so the + // caller can detect the gap. + Some(GrantedProjectPolicyResponse { + project_id, + project_name: decrypted_name, + has_permission: details.has_permission.unwrap_or(false), + policy: AccessPolicyResponse { + read: policy_model.read.unwrap_or(false), + write: policy_model.write.unwrap_or(false), + manage: policy_model.manage?, + }, + }) + }) + .collect(); + + Ok(GrantedPoliciesResponse { + granted_project_policies, + }) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/get_project.rs b/bitwarden_license/bitwarden-sm/src/access_policies/get_project.rs new file mode 100644 index 0000000000..cecf3a240f --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/get_project.rs @@ -0,0 +1,82 @@ +use bitwarden_core::{client::Client, key_management::SymmetricKeyId}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::{conversions, types::AccessPoliciesResponse}; + +#[derive(Error, Debug)] +pub enum GetProjectAccessPoliciesError { + #[error("Internal error: {0}")] + InternalError(String), + #[error("Crypto error: {0}")] + CryptoError(String), +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetProjectAccessPoliciesRequest { + pub project_id: Uuid, +} + +pub async fn get_project_access_policies( + client: &Client, + request: &GetProjectAccessPoliciesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let people = config + .api_client + .access_policies_api() + .get_project_people_access_policies(request.project_id) + .await + .map_err(|e| GetProjectAccessPoliciesError::InternalError(format!("{e:?}")))?; + + let sa = config + .api_client + .access_policies_api() + .get_project_service_accounts_access_policies(request.project_id) + .await + .map_err(|e| GetProjectAccessPoliciesError::InternalError(format!("{e:?}")))?; + + let org_id = client + .internal + .get_access_token_organization() + .ok_or_else(|| { + GetProjectAccessPoliciesError::CryptoError( + "Not authenticated as a service account".into(), + ) + })?; + let key_store = client.internal.get_key_store(); + let mut ctx = key_store.context(); + let org_key = SymmetricKeyId::Organization(org_id); + + let user_access_policies = people + .user_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(conversions::user_from_api) + .collect(); + + let group_access_policies = people + .group_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(conversions::group_from_api) + .collect(); + + let service_account_access_policies = sa + .service_account_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(|p| conversions::service_account_from_api(p, &mut ctx, org_key)) + .collect(); + + Ok(AccessPoliciesResponse { + user_access_policies, + group_access_policies, + service_account_access_policies, + }) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/get_secret.rs b/bitwarden_license/bitwarden-sm/src/access_policies/get_secret.rs new file mode 100644 index 0000000000..ff4254305d --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/get_secret.rs @@ -0,0 +1,75 @@ +use bitwarden_core::{client::Client, key_management::SymmetricKeyId}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::{conversions, types::AccessPoliciesResponse}; + +#[derive(Error, Debug)] +pub enum GetSecretAccessPoliciesError { + #[error("Internal error: {0}")] + InternalError(String), + #[error("Crypto error: {0}")] + CryptoError(String), +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetSecretAccessPoliciesRequest { + pub secret_id: Uuid, +} + +pub async fn get_secret_access_policies( + client: &Client, + request: &GetSecretAccessPoliciesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let response = config + .api_client + .access_policies_api() + .get_secret_access_policies(request.secret_id) + .await + .map_err(|e| GetSecretAccessPoliciesError::InternalError(format!("{e:?}")))?; + + let org_id = client + .internal + .get_access_token_organization() + .ok_or_else(|| { + GetSecretAccessPoliciesError::CryptoError( + "Not authenticated as a service account".into(), + ) + })?; + let key_store = client.internal.get_key_store(); + let mut ctx = key_store.context(); + let org_key = SymmetricKeyId::Organization(org_id); + + let user_access_policies = response + .user_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(conversions::user_from_api) + .collect(); + + let group_access_policies = response + .group_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(conversions::group_from_api) + .collect(); + + let service_account_access_policies = response + .service_account_access_policies + .unwrap_or_default() + .into_iter() + .filter_map(|p| conversions::service_account_from_api(p, &mut ctx, org_key)) + .collect(); + + Ok(AccessPoliciesResponse { + user_access_policies, + group_access_policies, + service_account_access_policies, + }) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/mod.rs b/bitwarden_license/bitwarden-sm/src/access_policies/mod.rs new file mode 100644 index 0000000000..3a963dbd69 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/mod.rs @@ -0,0 +1,31 @@ +mod conversions; +mod get_granted; +mod get_project; +mod get_secret; +mod potential_grantees; +mod put_granted; +mod put_project; +mod put_secret; +pub mod types; + +pub use get_granted::{GetGrantedPoliciesError, GetGrantedPoliciesRequest, get_granted_policies}; +pub use get_project::{ + GetProjectAccessPoliciesError, GetProjectAccessPoliciesRequest, get_project_access_policies, +}; +pub use get_secret::{ + GetSecretAccessPoliciesError, GetSecretAccessPoliciesRequest, get_secret_access_policies, +}; +pub use potential_grantees::{ + GetPotentialGranteesError, GetPotentialGranteesRequest, GranteeType, get_potential_grantees, +}; +pub use put_granted::{ + GrantedProjectEntry, PutGrantedPoliciesError, PutGrantedPoliciesRequest, put_granted_policies, +}; +pub use put_project::{ + PutProjectAccessPoliciesError, PutProjectAccessPoliciesRequest, put_project_access_policies, +}; +pub use types::{ + AccessPoliciesResponse, AccessPolicyEntry, AccessPolicyResponse, GrantedPoliciesResponse, + GrantedProjectPolicyResponse, GroupAccessPolicyResponse, PotentialGrantee, + PotentialGranteesResponse, ServiceAccountAccessPolicyResponse, UserAccessPolicyResponse, +}; diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/potential_grantees.rs b/bitwarden_license/bitwarden-sm/src/access_policies/potential_grantees.rs new file mode 100644 index 0000000000..281277e640 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/potential_grantees.rs @@ -0,0 +1,128 @@ +use bitwarden_core::{ + OrganizationId, + client::Client, + key_management::{KeyIds, SymmetricKeyId}, +}; +use bitwarden_crypto::{Decryptable, EncString, KeyStoreContext}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::types::{PotentialGrantee, PotentialGranteesResponse}; + +#[derive(Error, Debug)] +pub enum GetPotentialGranteesError { + #[error("Internal error: {0}")] + InternalError(String), + #[error("Crypto error: {0}")] + CryptoError(String), +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase")] +pub enum GranteeType { + /// Human users and groups + People, + /// Projects + Projects, + /// Service accounts + ServiceAccounts, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetPotentialGranteesRequest { + pub organization_id: Uuid, + pub grantee_type: GranteeType, +} + +pub async fn get_potential_grantees( + client: &Client, + request: &GetPotentialGranteesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let models_data = match request.grantee_type { + GranteeType::People => config + .api_client + .access_policies_api() + .get_people_potential_grantees(request.organization_id) + .await + .map_err(|e| GetPotentialGranteesError::InternalError(format!("{e:?}")))? + .data + .unwrap_or_default(), + GranteeType::Projects => config + .api_client + .access_policies_api() + .get_project_potential_grantees(request.organization_id) + .await + .map_err(|e| GetPotentialGranteesError::InternalError(format!("{e:?}")))? + .data + .unwrap_or_default(), + GranteeType::ServiceAccounts => config + .api_client + .access_policies_api() + .get_service_accounts_potential_grantees(request.organization_id) + .await + .map_err(|e| GetPotentialGranteesError::InternalError(format!("{e:?}")))? + .data + .unwrap_or_default(), + }; + + let needs_decryption = matches!( + request.grantee_type, + GranteeType::Projects | GranteeType::ServiceAccounts + ); + + let data = if needs_decryption { + let key_store = client.internal.get_key_store(); + let mut ctx = key_store.context(); + let org_key = SymmetricKeyId::Organization(OrganizationId::new(request.organization_id)); + + models_data + .into_iter() + .filter(|g| g.id.is_some()) + .map(|g| { + let decrypted_name = g + .name + .map(|n| decrypt_name(&n, &mut ctx, org_key)) + .transpose()?; + Ok(PotentialGrantee { + id: g.id.expect("filtered by is_some() above"), + name: decrypted_name, + r#type: g.r#type, + email: g.email, + }) + }) + .collect::, GetPotentialGranteesError>>()? + } else { + models_data + .into_iter() + .filter_map(|g| { + Some(PotentialGrantee { + id: g.id?, + name: g.name, + r#type: g.r#type, + email: g.email, + }) + }) + .collect() + }; + + Ok(PotentialGranteesResponse { data }) +} + +fn decrypt_name( + encrypted: &str, + ctx: &mut KeyStoreContext, + key: SymmetricKeyId, +) -> Result { + encrypted + .parse::() + .map_err(|e| GetPotentialGranteesError::CryptoError(format!("{e:?}")))? + .decrypt(ctx, key) + .map_err(|e| GetPotentialGranteesError::CryptoError(format!("{e:?}"))) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/put_granted.rs b/bitwarden_license/bitwarden-sm/src/access_policies/put_granted.rs new file mode 100644 index 0000000000..56246fa735 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/put_granted.rs @@ -0,0 +1,78 @@ +use bitwarden_api_api::models; +use bitwarden_core::client::Client; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::{ + get_granted::{GetGrantedPoliciesRequest, get_granted_policies}, + types::GrantedPoliciesResponse, +}; + +#[derive(Error, Debug)] +pub enum PutGrantedPoliciesError { + #[error("Internal error: {0}")] + InternalError(String), +} + +/// A single granted project policy entry. +/// `manage` is `bool` (not `Option`) to prevent silent downgrade on round-trips. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantedProjectEntry { + pub project_id: Uuid, + pub read: bool, + pub write: bool, + /// MUST be bool, not Option — prevents silent downgrade + pub manage: bool, +} + +/// Full-replace PUT request for service account granted policies (PUT semantics). +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutGrantedPoliciesRequest { + pub service_account_id: Uuid, + pub projects: Vec, +} + +pub async fn put_granted_policies( + client: &Client, + request: &PutGrantedPoliciesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let policy_requests: Vec<_> = request + .projects + .iter() + .map(|p| models::GrantedAccessPolicyRequest { + granted_id: p.project_id, + read: p.read, + write: p.write, + manage: p.manage, + }) + .collect(); + + let body = models::ServiceAccountGrantedPoliciesRequestModel { + project_granted_policy_requests: Some(policy_requests), + }; + + config + .api_client + .access_policies_api() + .put_service_account_granted_policies(request.service_account_id, Some(body)) + .await + .map_err(|e| PutGrantedPoliciesError::InternalError(format!("{e:?}")))?; + + // Re-fetch to return the updated state + get_granted_policies( + client, + &GetGrantedPoliciesRequest { + service_account_id: request.service_account_id, + }, + ) + .await + .map_err(|e| PutGrantedPoliciesError::InternalError(format!("{e:?}"))) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/put_project.rs b/bitwarden_license/bitwarden-sm/src/access_policies/put_project.rs new file mode 100644 index 0000000000..596999361d --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/put_project.rs @@ -0,0 +1,127 @@ +use bitwarden_api_api::models; +use bitwarden_core::client::Client; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::{ + get_project::{GetProjectAccessPoliciesRequest, get_project_access_policies}, + types::{AccessPoliciesResponse, AccessPolicyEntry}, +}; + +fn to_api_request(p: &AccessPolicyEntry) -> models::AccessPolicyRequest { + models::AccessPolicyRequest { + grantee_id: p.grantee_id, + read: p.read, + write: p.write, + manage: p.manage, + } +} + +#[derive(Error, Debug)] +pub enum PutProjectAccessPoliciesError { + #[error("Internal error: {0}")] + InternalError(String), +} + +/// Request to replace access policies on a project. +/// +/// Each field controls a separate server-side replace operation: +/// - `None` → skip this category entirely (no server call, existing policies unchanged) +/// - `Some(vec![])` → replace with empty list (removes all policies in this category) +/// - `Some(vec![...])` → replace with the provided list +/// +/// The people PUT (`/projects/{id}/access-policies/people`) is sent when **either** +/// `user_access_policies` or `group_access_policies` is `Some`. Within that PUT, +/// a `None` sub-field defaults to an empty list. +/// +/// The SA PUT (`/projects/{id}/access-policies/service-accounts`) is sent only when +/// `service_account_access_policies` is `Some`. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutProjectAccessPoliciesRequest { + pub project_id: Uuid, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_access_policies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_access_policies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_account_access_policies: Option>, +} + +pub async fn put_project_access_policies( + client: &Client, + request: &PutProjectAccessPoliciesRequest, +) -> Result { + let config = client.internal.get_api_configurations().await; + + let send_people = + request.user_access_policies.is_some() || request.group_access_policies.is_some(); + let send_sa = request.service_account_access_policies.is_some(); + + // PUT people policies (users + groups) — only if at least one is Some + if send_people { + let user_requests: Vec<_> = request + .user_access_policies + .as_deref() + .unwrap_or(&[]) + .iter() + .map(to_api_request) + .collect(); + + let group_requests: Vec<_> = request + .group_access_policies + .as_deref() + .unwrap_or(&[]) + .iter() + .map(to_api_request) + .collect(); + + let people_body = models::PeopleAccessPoliciesRequestModel { + user_access_policy_requests: Some(user_requests), + group_access_policy_requests: Some(group_requests), + }; + + config + .api_client + .access_policies_api() + .put_project_people_access_policies(request.project_id, Some(people_body)) + .await + .map_err(|e| PutProjectAccessPoliciesError::InternalError(format!("{e:?}")))?; + } + + // Non-atomic: people policies may have updated while this call fails + // PUT service account policies — only if Some + if send_sa { + let sa_requests: Vec<_> = request + .service_account_access_policies + .as_deref() + .unwrap_or(&[]) + .iter() + .map(to_api_request) + .collect(); + + let sa_body = models::ProjectServiceAccountsAccessPoliciesRequestModel { + service_account_access_policy_requests: Some(sa_requests), + }; + + config + .api_client + .access_policies_api() + .put_project_service_accounts_access_policies(request.project_id, Some(sa_body)) + .await + .map_err(|e| PutProjectAccessPoliciesError::InternalError(format!("{e:?}")))?; + } + + // Re-fetch the current state to return consistent data + get_project_access_policies( + client, + &GetProjectAccessPoliciesRequest { + project_id: request.project_id, + }, + ) + .await + .map_err(|e| PutProjectAccessPoliciesError::InternalError(format!("{e:?}"))) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/put_secret.rs b/bitwarden_license/bitwarden-sm/src/access_policies/put_secret.rs new file mode 100644 index 0000000000..d859bc2f2c --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/put_secret.rs @@ -0,0 +1,46 @@ +// Stub: not yet wired into the public API (pending OpenAPI spec regeneration). +#![allow(dead_code)] + +use bitwarden_core::client::Client; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::access_policies::types::AccessPolicyEntry; + +#[derive(Error, Debug)] +pub enum PutSecretAccessPoliciesError { + #[error("Not implemented: {feature}")] + NotImplemented { feature: &'static str }, +} + +/// Request to replace access policies on a secret. See +/// [`super::put_project::PutProjectAccessPoliciesRequest`] for the `None` vs `Some(vec![])` vs +/// `Some(vec![...])` semantics. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutSecretAccessPoliciesRequest { + pub secret_id: Uuid, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_access_policies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_access_policies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_account_access_policies: Option>, +} + +/// PUT /secrets/{id}/access-policies stub. +/// +/// NOTE: The `put_secret_access_policies` endpoint is not yet available in `bitwarden-api-api` +/// (the OpenAPI spec has not been regenerated after the server change). This stub returns +/// NotImplemented until the API spec is regenerated and a proper PUT call can be made. +pub fn put_secret_access_policies( + _client: &Client, + _request: &PutSecretAccessPoliciesRequest, +) -> Result<(), PutSecretAccessPoliciesError> { + Err(PutSecretAccessPoliciesError::NotImplemented { + feature: "put_secret access policies", + }) +} diff --git a/bitwarden_license/bitwarden-sm/src/access_policies/types.rs b/bitwarden_license/bitwarden-sm/src/access_policies/types.rs new file mode 100644 index 0000000000..5bc5170535 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/access_policies/types.rs @@ -0,0 +1,144 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Core access permissions for a single policy entry. +/// `manage` is `bool` (never `Option`) to prevent silent downgrade on round-trips. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AccessPolicyResponse { + pub read: bool, + pub write: bool, + /// MUST be bool, not Option — prevents silent downgrade + pub manage: bool, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UserAccessPolicyResponse { + pub organization_user_id: Uuid, + pub organization_user_name: Option, + pub current_user: bool, + #[serde(flatten)] + pub policy: AccessPolicyResponse, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GroupAccessPolicyResponse { + pub group_id: Uuid, + pub group_name: Option, + pub current_user_in_group: bool, + #[serde(flatten)] + pub policy: AccessPolicyResponse, +} + +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ServiceAccountAccessPolicyResponse { + pub service_account_id: Uuid, + pub service_account_name: Option, + #[serde(flatten)] + pub policy: AccessPolicyResponse, +} + +/// Combined response for project or secret access policies. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AccessPoliciesResponse { + pub user_access_policies: Vec, + pub group_access_policies: Vec, + pub service_account_access_policies: Vec, +} + +/// Request entry for a single access policy on a project, secret, or service account. +/// `manage` is `bool` (never `Option`) to prevent silent downgrade on round-trips. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccessPolicyEntry { + pub grantee_id: Uuid, + pub read: bool, + pub write: bool, + /// MUST be bool, not Option — prevents silent downgrade + pub manage: bool, +} + +/// Response for a single granted project policy (used on service account granted policies). +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GrantedProjectPolicyResponse { + pub project_id: Uuid, + pub project_name: Option, + pub has_permission: bool, + #[serde(flatten)] + pub policy: AccessPolicyResponse, +} + +/// Response for service account granted policies. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GrantedPoliciesResponse { + pub granted_project_policies: Vec, +} + +/// A potential grantee (user, group, project, or service account). +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PotentialGrantee { + pub id: Uuid, + pub name: Option, + pub r#type: Option, + pub email: Option, +} + +/// Response for potential grantees. +#[allow(missing_docs)] +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PotentialGranteesResponse { + pub data: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manage_false_is_present_in_serialized_json() { + let entry = AccessPolicyEntry { + grantee_id: uuid::Uuid::new_v4(), + read: true, + write: true, + manage: false, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["manage"], + serde_json::Value::Bool(false), + "manage:false must be present in serialized JSON — omission causes server to silently bind false" + ); + } + + #[test] + fn manage_true_is_present_in_serialized_json() { + let entry = AccessPolicyEntry { + grantee_id: uuid::Uuid::new_v4(), + read: true, + write: true, + manage: true, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["manage"], serde_json::Value::Bool(true),); + } +} diff --git a/bitwarden_license/bitwarden-sm/src/client.rs b/bitwarden_license/bitwarden-sm/src/client.rs index c38502ac86..48cdf56da6 100644 --- a/bitwarden_license/bitwarden-sm/src/client.rs +++ b/bitwarden_license/bitwarden-sm/src/client.rs @@ -4,7 +4,7 @@ pub use bitwarden_core::ClientSettings; use bitwarden_core::{OrganizationId, auth::auth_client::AuthClient}; use bitwarden_generators::GeneratorClientsExt; -use crate::{ProjectsClient, SecretsClient}; +use crate::{AccessPoliciesClient, ProjectsClient, SecretsClient}; /// The main struct for interacting with the Secrets Manager service through the SM SDK. pub struct SecretsManagerClient { @@ -34,6 +34,11 @@ impl SecretsManagerClient { self.client.auth() } + /// Get access to the Access Policies API + pub fn access_policies(&self) -> AccessPoliciesClient { + AccessPoliciesClient::new(self.client.clone()) + } + /// Get access to the Generators API pub fn generator(&self) -> bitwarden_generators::GeneratorClient { self.client.generator() diff --git a/bitwarden_license/bitwarden-sm/src/client_access_policies.rs b/bitwarden_license/bitwarden-sm/src/client_access_policies.rs new file mode 100644 index 0000000000..e0ca1799a8 --- /dev/null +++ b/bitwarden_license/bitwarden-sm/src/client_access_policies.rs @@ -0,0 +1,83 @@ +use bitwarden_core::Client; + +use crate::access_policies::{ + AccessPoliciesResponse, GetGrantedPoliciesError, GetGrantedPoliciesRequest, + GetPotentialGranteesError, GetPotentialGranteesRequest, GetProjectAccessPoliciesError, + GetProjectAccessPoliciesRequest, GetSecretAccessPoliciesError, GetSecretAccessPoliciesRequest, + GrantedPoliciesResponse, PotentialGranteesResponse, PutGrantedPoliciesError, + PutGrantedPoliciesRequest, PutProjectAccessPoliciesError, PutProjectAccessPoliciesRequest, + get_granted_policies, get_potential_grantees, get_project_access_policies, + get_secret_access_policies, put_granted_policies, put_project_access_policies, +}; + +#[allow(missing_docs)] +pub struct AccessPoliciesClient { + pub(crate) client: Client, +} + +impl AccessPoliciesClient { + #[allow(missing_docs)] + pub fn new(client: Client) -> Self { + Self { client } + } + + #[allow(missing_docs)] + pub async fn get_project_policies( + &self, + input: &GetProjectAccessPoliciesRequest, + ) -> Result { + get_project_access_policies(&self.client, input).await + } + + #[allow(missing_docs)] + pub async fn put_project_policies( + &self, + input: &PutProjectAccessPoliciesRequest, + ) -> Result { + put_project_access_policies(&self.client, input).await + } + + #[allow(missing_docs)] + pub async fn get_secret_policies( + &self, + input: &GetSecretAccessPoliciesRequest, + ) -> Result { + get_secret_access_policies(&self.client, input).await + } + + #[allow(missing_docs)] + pub async fn get_granted_policies( + &self, + input: &GetGrantedPoliciesRequest, + ) -> Result { + get_granted_policies(&self.client, input).await + } + + #[allow(missing_docs)] + pub async fn put_granted_policies( + &self, + input: &PutGrantedPoliciesRequest, + ) -> Result { + put_granted_policies(&self.client, input).await + } + + #[allow(missing_docs)] + pub async fn get_potential_grantees( + &self, + input: &GetPotentialGranteesRequest, + ) -> Result { + get_potential_grantees(&self.client, input).await + } +} + +#[allow(missing_docs)] +pub trait AccessPoliciesClientExt { + #[allow(missing_docs)] + fn access_policies(&self) -> AccessPoliciesClient; +} + +impl AccessPoliciesClientExt for Client { + fn access_policies(&self) -> AccessPoliciesClient { + AccessPoliciesClient::new(self.clone()) + } +} diff --git a/bitwarden_license/bitwarden-sm/src/lib.rs b/bitwarden_license/bitwarden-sm/src/lib.rs index 919c5328ce..8e514dcb8d 100644 --- a/bitwarden_license/bitwarden-sm/src/lib.rs +++ b/bitwarden_license/bitwarden-sm/src/lib.rs @@ -1,6 +1,9 @@ #![doc = include_str!("../README.md")] +#[allow(missing_docs)] +pub mod access_policies; pub mod client; +mod client_access_policies; mod client_projects; mod client_secrets; mod error; @@ -9,13 +12,15 @@ pub mod projects; #[allow(missing_docs)] pub mod secrets; +// Re-exports for backwards compatibility with sdk-sm consumers that import via bitwarden_sm::* pub use bitwarden_core::{ - DeviceType, + ClientSettings, DeviceType, auth::{ AccessToken, login::{AccessTokenLoginRequest, AccessTokenLoginResponse}, }, }; -pub use client::{ClientSettings, SecretsManagerClient}; +pub use client::SecretsManagerClient; +pub use client_access_policies::{AccessPoliciesClient, AccessPoliciesClientExt}; pub use client_projects::ProjectsClient; pub use client_secrets::SecretsClient; diff --git a/crates/bitwarden-api-api/src/lib.rs b/crates/bitwarden-api-api/src/lib.rs index ae76355787..93ffd574da 100644 --- a/crates/bitwarden-api-api/src/lib.rs +++ b/crates/bitwarden-api-api/src/lib.rs @@ -13,3 +13,52 @@ pub mod apis; pub mod models; pub use bitwarden_api_base::{Configuration, ContentType, Error, ResponseContent}; + +#[cfg(test)] +mod manage_field_guard { + // Compile-time assertions: these functions will fail to compile if manage regresses to + // Option + fn _access_policy_request_manage_is_bool(r: &crate::models::AccessPolicyRequest) -> bool { + r.manage + } + + fn _granted_access_policy_request_manage_is_bool( + r: &crate::models::GrantedAccessPolicyRequest, + ) -> bool { + r.manage + } + + #[test] + fn access_policy_request_manage_serializes_explicit_false() { + let r = crate::models::AccessPolicyRequest { + grantee_id: uuid::Uuid::nil(), + read: false, + write: false, + manage: false, + }; + let json = serde_json::to_string(&r).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + v["manage"], + serde_json::Value::Bool(false), + "manage:false must serialize explicitly — regression to Option with skip_serializing_if would omit it" + ); + } + + #[test] + fn granted_access_policy_request_manage_serializes_explicit_false() { + let r = crate::models::GrantedAccessPolicyRequest { + granted_id: uuid::Uuid::nil(), + read: false, + write: false, + manage: false, + }; + let json = serde_json::to_string(&r).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + v["manage"], + serde_json::Value::Bool(false), + "manage:false must serialize explicitly — regression to Option with skip_serializing_if would omit it" + ); + } +} diff --git a/crates/bitwarden-api-api/src/models/access_policy_request.rs b/crates/bitwarden-api-api/src/models/access_policy_request.rs index 42921e8a20..9187cc3f9b 100644 --- a/crates/bitwarden-api-api/src/models/access_policy_request.rs +++ b/crates/bitwarden-api-api/src/models/access_policy_request.rs @@ -20,14 +20,22 @@ pub struct AccessPolicyRequest { pub read: bool, #[serde(rename = "write", alias = "Write")] pub write: bool, + #[serde(rename = "manage", alias = "Manage")] + pub manage: bool, } impl AccessPolicyRequest { - pub fn new(grantee_id: uuid::Uuid, read: bool, write: bool) -> AccessPolicyRequest { + pub fn new( + grantee_id: uuid::Uuid, + read: bool, + write: bool, + manage: bool, + ) -> AccessPolicyRequest { AccessPolicyRequest { grantee_id, read, write, + manage, } } } diff --git a/crates/bitwarden-api-api/src/models/granted_access_policy_request.rs b/crates/bitwarden-api-api/src/models/granted_access_policy_request.rs index 8d3587a8af..76ae3bdcaa 100644 --- a/crates/bitwarden-api-api/src/models/granted_access_policy_request.rs +++ b/crates/bitwarden-api-api/src/models/granted_access_policy_request.rs @@ -20,14 +20,22 @@ pub struct GrantedAccessPolicyRequest { pub read: bool, #[serde(rename = "write", alias = "Write")] pub write: bool, + #[serde(rename = "manage", alias = "Manage")] + pub manage: bool, } impl GrantedAccessPolicyRequest { - pub fn new(granted_id: uuid::Uuid, read: bool, write: bool) -> GrantedAccessPolicyRequest { + pub fn new( + granted_id: uuid::Uuid, + read: bool, + write: bool, + manage: bool, + ) -> GrantedAccessPolicyRequest { GrantedAccessPolicyRequest { granted_id, read, write, + manage, } } } diff --git a/crates/bitwarden-api-api/src/models/granted_project_access_policy_response_model.rs b/crates/bitwarden-api-api/src/models/granted_project_access_policy_response_model.rs index 9dca2f8229..553d9988f1 100644 --- a/crates/bitwarden-api-api/src/models/granted_project_access_policy_response_model.rs +++ b/crates/bitwarden-api-api/src/models/granted_project_access_policy_response_model.rs @@ -44,6 +44,8 @@ pub struct GrantedProjectAccessPolicyResponseModel { skip_serializing_if = "Option::is_none" )] pub granted_project_name: Option, + #[serde(rename = "manage", alias = "Manage", default)] + pub manage: Option, } impl GrantedProjectAccessPolicyResponseModel { @@ -54,6 +56,7 @@ impl GrantedProjectAccessPolicyResponseModel { write: None, granted_project_id: None, granted_project_name: None, + manage: None, } } } diff --git a/crates/bitwarden-api-api/src/models/group_access_policy_response_model.rs b/crates/bitwarden-api-api/src/models/group_access_policy_response_model.rs index c938fa7fc5..497a6f70b1 100644 --- a/crates/bitwarden-api-api/src/models/group_access_policy_response_model.rs +++ b/crates/bitwarden-api-api/src/models/group_access_policy_response_model.rs @@ -50,6 +50,8 @@ pub struct GroupAccessPolicyResponseModel { skip_serializing_if = "Option::is_none" )] pub current_user_in_group: Option, + #[serde(rename = "manage", alias = "Manage", default)] + pub manage: Option, } impl GroupAccessPolicyResponseModel { @@ -61,6 +63,7 @@ impl GroupAccessPolicyResponseModel { group_id: None, group_name: None, current_user_in_group: None, + manage: None, } } } diff --git a/crates/bitwarden-api-api/src/models/service_account_access_policy_response_model.rs b/crates/bitwarden-api-api/src/models/service_account_access_policy_response_model.rs index 2458a2b0a7..81aa56094f 100644 --- a/crates/bitwarden-api-api/src/models/service_account_access_policy_response_model.rs +++ b/crates/bitwarden-api-api/src/models/service_account_access_policy_response_model.rs @@ -44,6 +44,8 @@ pub struct ServiceAccountAccessPolicyResponseModel { skip_serializing_if = "Option::is_none" )] pub service_account_name: Option, + #[serde(rename = "manage", alias = "Manage", default)] + pub manage: Option, } impl ServiceAccountAccessPolicyResponseModel { @@ -54,6 +56,7 @@ impl ServiceAccountAccessPolicyResponseModel { write: None, service_account_id: None, service_account_name: None, + manage: None, } } } diff --git a/crates/bitwarden-api-api/src/models/user_access_policy_response_model.rs b/crates/bitwarden-api-api/src/models/user_access_policy_response_model.rs index 67432f3ba3..55bf39a999 100644 --- a/crates/bitwarden-api-api/src/models/user_access_policy_response_model.rs +++ b/crates/bitwarden-api-api/src/models/user_access_policy_response_model.rs @@ -50,6 +50,8 @@ pub struct UserAccessPolicyResponseModel { skip_serializing_if = "Option::is_none" )] pub current_user: Option, + #[serde(rename = "manage", alias = "Manage", default)] + pub manage: Option, } impl UserAccessPolicyResponseModel { @@ -61,6 +63,7 @@ impl UserAccessPolicyResponseModel { organization_user_id: None, organization_user_name: None, current_user: None, + manage: None, } } } diff --git a/package.json b/package.json index ce48be3ff1..6c7824567e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "license": "SEE LICENSE IN LICENSE", "author": "Bitwarden Inc. (https://bitwarden.com)", + "type": "module", "main": "index.js", "scripts": { "lint": "prettier --check .",