diff --git a/crates/cli/src/commands/bucket.rs b/crates/cli/src/commands/bucket.rs index de17287..d898744 100644 --- a/crates/cli/src/commands/bucket.rs +++ b/crates/cli/src/commands/bucket.rs @@ -8,7 +8,10 @@ use clap::{Args, Subcommand}; use crate::exit_code::ExitCode; use crate::output::OutputConfig; -use super::{anonymous, cors, encryption, event, ilm, lock, ls, mb, quota, rb, replicate, version}; +use super::{ + anonymous, cors, encryption, event, ilm, lock, ls, mb, public_access_block, quota, rb, + replicate, version, +}; const BUCKET_AFTER_HELP: &str = "\ Examples: @@ -19,6 +22,7 @@ Examples: rc bucket cors list local/my-bucket rc bucket encryption info local/my-bucket rc bucket lock info local/my-bucket + rc bucket public-access-block get local/my-bucket rc bucket replication status local/my-bucket"; /// Manage bucket-oriented workflows @@ -63,6 +67,10 @@ pub enum BucketCommands { #[command(subcommand)] Quota(quota::QuotaCommands), + /// Manage bucket Public Access Block settings + #[command(name = "public-access-block", subcommand)] + PublicAccessBlock(public_access_block::PublicAccessBlockCommands), + /// Manage anonymous bucket access #[command(subcommand)] Anonymous(anonymous::AnonymousCommands), @@ -115,6 +123,13 @@ pub async fn execute(args: BucketArgs, output_config: OutputConfig) -> ExitCode )) .await } + BucketCommands::PublicAccessBlock(cmd) => { + Box::pin(public_access_block::execute( + public_access_block::PublicAccessBlockArgs { command: cmd }, + output_config, + )) + .await + } BucketCommands::Anonymous(cmd) => { Box::pin(anonymous::execute( anonymous::AnonymousArgs { command: cmd }, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a59d9e3..d9d5955 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -52,6 +52,7 @@ mod object_identity; mod ops_output; mod ping; mod pipe; +mod public_access_block; mod quota; mod rb; mod ready; diff --git a/crates/cli/src/commands/public_access_block.rs b/crates/cli/src/commands/public_access_block.rs new file mode 100644 index 0000000..501b278 --- /dev/null +++ b/crates/cli/src/commands/public_access_block.rs @@ -0,0 +1,389 @@ +//! public-access-block command - Manage bucket-level public access controls. + +use clap::{Args, Subcommand}; +use rc_core::{AliasManager, ObjectStore as _, PublicAccessBlockConfiguration}; +use rc_s3::S3Client; +use serde::Serialize; +use tokio::io::AsyncReadExt; + +use crate::exit_code::ExitCode; +use crate::output::{Formatter, OutputConfig}; + +const AFTER_HELP: &str = "\ +Examples: + rc bucket public-access-block get local/my-bucket + rc bucket public-access-block set local/my-bucket public-access-block.json + rc bucket public-access-block set local/my-bucket - + rc bucket public-access-block remove local/my-bucket --yes"; + +/// Manage S3 Public Access Block settings for one bucket. +#[derive(Args, Debug)] +pub struct PublicAccessBlockArgs { + #[command(subcommand)] + pub command: PublicAccessBlockCommands, +} + +#[derive(Subcommand, Debug)] +#[command(after_help = AFTER_HELP)] +pub enum PublicAccessBlockCommands { + /// Show the current Public Access Block settings. + Get(BucketArg), + /// Replace the Public Access Block settings from a JSON file or stdin. + Set(SetArgs), + /// Remove Public Access Block settings; --yes is required because this can allow public access. + Remove(RemoveArgs), +} + +#[derive(Args, Debug)] +pub struct BucketArg { + /// Bucket path (alias/bucket). + pub path: String, +} + +#[derive(Args, Debug)] +pub struct SetArgs { + /// Bucket path (alias/bucket). + pub path: String, + /// JSON configuration file, or '-' to read from stdin. + pub source: String, +} + +#[derive(Args, Debug)] +pub struct RemoveArgs { + /// Bucket path (alias/bucket). + pub path: String, + /// Confirm removal of the public access protection. + #[arg(long)] + pub yes: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigurationOutput { + schema_version: u8, + #[serde(rename = "type")] + output_type: &'static str, + status: &'static str, + data: ConfigurationData, +} + +#[derive(Debug, Serialize)] +struct ConfigurationData { + bucket: String, + operation: &'static str, + configuration: Option, +} + +/// Execute a Public Access Block command. +pub async fn execute(args: PublicAccessBlockArgs, output_config: OutputConfig) -> ExitCode { + match args.command { + PublicAccessBlockCommands::Get(args) => execute_get(args, output_config).await, + PublicAccessBlockCommands::Set(args) => execute_set(args, output_config).await, + PublicAccessBlockCommands::Remove(args) => execute_remove(args, output_config).await, + } +} + +async fn execute_get(args: BucketArg, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(path) => path, + Err(error) => return fail_message(&formatter, ExitCode::UsageError, error, None), + }; + let client = match setup_client(&alias_name, &formatter).await { + Ok(client) => client, + Err(code) => return code, + }; + + match client.get_bucket_public_access_block(&bucket).await { + Ok(configuration) => { + if formatter.is_json() { + formatter.json(&success_output(bucket, "get", configuration)); + } else if let Some(configuration) = configuration { + formatter.println(&format!("Public Access Block for '{bucket}':")); + formatter.println(&format!( + " Block public ACLs: {}", + configuration.block_public_acls + )); + formatter.println(&format!( + " Ignore public ACLs: {}", + configuration.ignore_public_acls + )); + formatter.println(&format!( + " Block public policies: {}", + configuration.block_public_policy + )); + formatter.println(&format!( + " Restrict public buckets: {}", + configuration.restrict_public_buckets + )); + } else { + formatter.println("No Public Access Block configuration is set."); + } + ExitCode::Success + } + Err(error) => super::ops_output::emit_error( + &formatter, + "public_access_block", + "Failed to get Public Access Block", + &error, + None, + None, + None, + ), + } +} + +async fn execute_set(args: SetArgs, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(path) => path, + Err(error) => return fail_message(&formatter, ExitCode::UsageError, error, None), + }; + let contents = match read_source(&args.source).await { + Ok(contents) => contents, + Err(error) => { + return super::ops_output::emit_message( + &formatter, + "public_access_block", + ExitCode::GeneralError, + format!("Failed to read Public Access Block configuration: {error}"), + None, + ); + } + }; + let configuration = match serde_json::from_str::(&contents) { + Ok(configuration) => configuration, + Err(error) => { + return super::ops_output::emit_message( + &formatter, + "public_access_block", + ExitCode::UsageError, + format!("Invalid Public Access Block configuration: {error}"), + None, + ); + } + }; + let client = match setup_client(&alias_name, &formatter).await { + Ok(client) => client, + Err(code) => return code, + }; + + match client + .set_bucket_public_access_block(&bucket, configuration) + .await + { + Ok(()) => { + if formatter.is_json() { + formatter.json(&success_output(bucket, "set", Some(configuration))); + } else { + formatter.success("Public Access Block configuration applied."); + } + ExitCode::Success + } + Err(error) => super::ops_output::emit_error( + &formatter, + "public_access_block", + "Failed to set Public Access Block", + &error, + None, + None, + None, + ), + } +} + +async fn execute_remove(args: RemoveArgs, output_config: OutputConfig) -> ExitCode { + let formatter = Formatter::new(output_config); + if !args.yes { + return fail_message( + &formatter, + ExitCode::UsageError, + "Removing Public Access Block can expose a bucket to public policies.".to_string(), + Some("Review the bucket policy, then repeat with --yes to confirm removal."), + ); + } + let (alias_name, bucket) = match parse_bucket_path(&args.path) { + Ok(path) => path, + Err(error) => return fail_message(&formatter, ExitCode::UsageError, error, None), + }; + let client = match setup_client(&alias_name, &formatter).await { + Ok(client) => client, + Err(code) => return code, + }; + + match client.delete_bucket_public_access_block(&bucket).await { + Ok(()) => { + if formatter.is_json() { + formatter.json(&success_output(bucket, "remove", None)); + } else { + formatter.success("Public Access Block configuration removed."); + } + ExitCode::Success + } + Err(error) => super::ops_output::emit_error( + &formatter, + "public_access_block", + "Failed to remove Public Access Block", + &error, + None, + None, + None, + ), + } +} + +fn success_output( + bucket: String, + operation: &'static str, + configuration: Option, +) -> ConfigurationOutput { + ConfigurationOutput { + schema_version: 3, + output_type: "public_access_block", + status: "success", + data: ConfigurationData { + bucket, + operation, + configuration, + }, + } +} + +async fn setup_client(alias_name: &str, formatter: &Formatter) -> Result { + let alias_manager = match AliasManager::new() { + Ok(manager) => manager, + Err(error) => { + return Err(fail_message( + formatter, + ExitCode::GeneralError, + format!("Failed to load aliases: {error}"), + None, + )); + } + }; + let alias = match alias_manager.get(alias_name) { + Ok(alias) => alias, + Err(_) => { + return Err(fail_message( + formatter, + ExitCode::NotFound, + format!("Alias '{alias_name}' not found"), + Some("Run `rc alias list` or add one with `rc alias set ...`."), + )); + } + }; + S3Client::new(alias).await.map_err(|error| { + fail_message( + formatter, + ExitCode::NetworkError, + format!("Failed to create S3 client: {error}"), + None, + ) + }) +} + +fn fail_message( + formatter: &Formatter, + code: ExitCode, + message: String, + suggestion: Option<&str>, +) -> ExitCode { + super::ops_output::emit_message(formatter, "public_access_block", code, message, suggestion) +} + +fn parse_bucket_path(path: &str) -> Result<(String, String), String> { + let (alias, bucket) = path + .split_once('/') + .ok_or_else(|| "Bucket path must be in format alias/bucket".to_string())?; + let bucket = bucket.trim_end_matches('/'); + if alias.trim().is_empty() || bucket.is_empty() || bucket.contains('/') { + return Err("Bucket path must be in format alias/bucket".to_string()); + } + Ok((alias.to_string(), bucket.to_string())) +} + +async fn read_source(source: &str) -> Result { + if source == "-" { + let mut bytes = Vec::new(); + tokio::io::stdin() + .read_to_end(&mut bytes) + .await + .map_err(|error| error.to_string())?; + String::from_utf8(bytes).map_err(|error| error.to_string()) + } else { + tokio::fs::read_to_string(source) + .await + .map_err(|error| error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_bucket_path() { + assert_eq!( + parse_bucket_path("local/raw/"), + Ok(("local".to_string(), "raw".to_string())) + ); + } + + #[test] + fn rejects_invalid_bucket_paths() { + for path in ["", "local", "/bucket", "local/", "local/bucket/prefix"] { + assert!(parse_bucket_path(path).is_err(), "accepted {path:?}"); + } + } + + #[test] + fn parses_complete_public_access_block_json() { + let configuration: PublicAccessBlockConfiguration = serde_json::from_value(json!({ + "block_public_acls": true, + "ignore_public_acls": true, + "block_public_policy": true, + "restrict_public_buckets": true + })) + .expect("complete configuration"); + assert_eq!( + configuration, + PublicAccessBlockConfiguration::secure_default() + ); + } + + #[tokio::test] + async fn remove_requires_confirmation_before_network_setup() { + let code = execute( + PublicAccessBlockArgs { + command: PublicAccessBlockCommands::Remove(RemoveArgs { + path: "local/raw".to_string(), + yes: false, + }), + }, + OutputConfig::default(), + ) + .await; + assert_eq!(code, ExitCode::UsageError); + } + + #[tokio::test] + async fn set_rejects_configuration_missing_a_flag_before_network_setup() { + let source = tempfile::NamedTempFile::new().expect("temporary config file"); + std::fs::write( + source.path(), + r#"{"block_public_acls":true,"ignore_public_acls":true,"block_public_policy":true}"#, + ) + .expect("write incomplete configuration"); + let code = execute( + PublicAccessBlockArgs { + command: PublicAccessBlockCommands::Set(SetArgs { + path: "local/raw".to_string(), + source: source.path().display().to_string(), + }), + }, + OutputConfig::default(), + ) + .await; + assert_eq!(code, ExitCode::UsageError); + } +} diff --git a/crates/cli/tests/fixtures/output_v3/public_access_block/empty.json b/crates/cli/tests/fixtures/output_v3/public_access_block/empty.json new file mode 100644 index 0000000..6dadb9c --- /dev/null +++ b/crates/cli/tests/fixtures/output_v3/public_access_block/empty.json @@ -0,0 +1 @@ +{"schema_version":3,"type":"public_access_block","status":"success","data":{"bucket":"archive","operation":"get","configuration":null}} diff --git a/crates/cli/tests/fixtures/output_v3/public_access_block/error.json b/crates/cli/tests/fixtures/output_v3/public_access_block/error.json new file mode 100644 index 0000000..2825933 --- /dev/null +++ b/crates/cli/tests/fixtures/output_v3/public_access_block/error.json @@ -0,0 +1 @@ +{"schema_version":3,"type":"public_access_block","status":"error","error":{"type":"network_error","message":"Failed to get Public Access Block","retryable":true,"suggestion":null}} diff --git a/crates/cli/tests/fixtures/output_v3/public_access_block/success.json b/crates/cli/tests/fixtures/output_v3/public_access_block/success.json new file mode 100644 index 0000000..2bd079d --- /dev/null +++ b/crates/cli/tests/fixtures/output_v3/public_access_block/success.json @@ -0,0 +1 @@ +{"schema_version":3,"type":"public_access_block","status":"success","data":{"bucket":"archive","operation":"set","configuration":{"block_public_acls":true,"ignore_public_acls":true,"block_public_policy":true,"restrict_public_buckets":true}}} diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs index 3582481..887938e 100644 --- a/crates/cli/tests/help_contract.rs +++ b/crates/cli/tests/help_contract.rs @@ -247,12 +247,23 @@ fn top_level_command_help_contract() { "lock", "quota", "anonymous", + "public-access-block", "lifecycle", "replication", "Examples:", "rc bucket list local/", ], }, + HelpCase { + args: &["bucket", "public-access-block"], + usage: "Usage: rc bucket public-access-block [OPTIONS] ", + expected_tokens: &["get", "set", "remove", "Examples:"], + }, + HelpCase { + args: &["bucket", "public-access-block", "set"], + usage: "Usage: rc bucket public-access-block set [OPTIONS] ", + expected_tokens: &["JSON configuration", "stdin"], + }, HelpCase { args: &["bucket", "encryption"], usage: "Usage: rc bucket encryption [OPTIONS] ", diff --git a/crates/cli/tests/output_schema_v3.rs b/crates/cli/tests/output_schema_v3.rs index 799c6b8..b2d12dd 100644 --- a/crates/cli/tests/output_schema_v3.rs +++ b/crates/cli/tests/output_schema_v3.rs @@ -10,6 +10,7 @@ const V3_FAMILIES: &[&str] = &[ "capabilities", "versioned_objects", "locks", + "public_access_block", "multipart_uploads", "watch_event", "health", diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9e04bd6..0ad149b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,6 +22,7 @@ pub mod object_key; pub mod object_lock; pub mod ops; pub mod path; +pub mod public_access_block; pub mod replication; pub mod retry; pub mod select; @@ -65,6 +66,7 @@ pub use ops::{ UsageScanRequest, UsageScope, UsageSnapshotApi, UsageSource, }; pub use path::{ParsedPath, RemotePath, parse_object_path, parse_path}; +pub use public_access_block::PublicAccessBlockConfiguration; pub use replication::{ BucketTarget, BucketTargetCredentials, ReplicationCheckPhase, ReplicationCheckPhaseState, ReplicationCheckPhases, ReplicationCheckResult, ReplicationCheckStatus, ReplicationCheckTarget, diff --git a/crates/core/src/public_access_block.rs b/crates/core/src/public_access_block.rs new file mode 100644 index 0000000..9a36e8c --- /dev/null +++ b/crates/core/src/public_access_block.rs @@ -0,0 +1,77 @@ +//! Bucket-level controls that reject public S3 access policies. + +use serde::{Deserialize, Serialize}; + +/// S3 Public Access Block settings for one bucket. +/// +/// Every flag is explicit so a partial configuration cannot silently weaken +/// the requested access boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublicAccessBlockConfiguration { + /// Reject bucket and object ACLs that grant public access. + pub block_public_acls: bool, + /// Ignore public grants in existing bucket and object ACLs. + pub ignore_public_acls: bool, + /// Reject bucket policies that grant public access. + pub block_public_policy: bool, + /// Restrict access to buckets with public policies to authorized AWS services and principals. + pub restrict_public_buckets: bool, +} + +impl PublicAccessBlockConfiguration { + /// Return the restrictive configuration used for bootstrap-created buckets. + pub const fn secure_default() -> Self { + Self { + block_public_acls: true, + ignore_public_acls: true, + block_public_policy: true, + restrict_public_buckets: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn secure_default_blocks_all_public_access_paths() { + assert_eq!( + PublicAccessBlockConfiguration::secure_default(), + PublicAccessBlockConfiguration { + block_public_acls: true, + ignore_public_acls: true, + block_public_policy: true, + restrict_public_buckets: true, + } + ); + } + + #[test] + fn configuration_requires_every_flag() { + let error = serde_json::from_value::(json!({ + "block_public_acls": true, + "ignore_public_acls": true, + "block_public_policy": true + })) + .expect_err("missing flag must not default to permissive access"); + + assert!(error.to_string().contains("restrict_public_buckets")); + } + + #[test] + fn configuration_rejects_unknown_flags() { + let error = serde_json::from_value::(json!({ + "block_public_acls": true, + "ignore_public_acls": true, + "block_public_policy": true, + "restrict_public_buckets": true, + "unknown": true + })) + .expect_err("unknown flags must fail closed"); + + assert!(error.to_string().contains("unknown field")); + } +} diff --git a/crates/core/src/traits.rs b/crates/core/src/traits.rs index 8f60413..60ab159 100644 --- a/crates/core/src/traits.rs +++ b/crates/core/src/traits.rs @@ -21,6 +21,7 @@ use crate::object_lock::{ BucketObjectLockConfiguration, LegalHoldStatus, ObjectLockOptions, ObjectRetention, }; use crate::path::RemotePath; +use crate::public_access_block::PublicAccessBlockConfiguration; use crate::replication::{ ReplicationCheckResult, ReplicationConfiguration, ReplicationResyncStartOptions, ReplicationResyncStartResult, ReplicationResyncStatus, @@ -1028,6 +1029,50 @@ pub trait ObjectStore: Send + Sync { /// Remove bucket policy (set anonymous access to private). async fn delete_bucket_policy(&self, bucket: &str) -> Result<()>; + /// Get the bucket's Public Access Block configuration. + /// + /// Returns `None` when the bucket has no configuration. + /// + /// # Errors + /// + /// Returns `UnsupportedFeature` when the object store does not implement this operation. + async fn get_bucket_public_access_block( + &self, + _bucket: &str, + ) -> Result> { + Err(Error::UnsupportedFeature( + "Bucket Public Access Block inspection is not supported by this object store".into(), + )) + } + + /// Replace the bucket's Public Access Block configuration. + /// + /// # Errors + /// + /// Returns `UnsupportedFeature` when the object store does not implement this operation. + async fn set_bucket_public_access_block( + &self, + _bucket: &str, + _configuration: PublicAccessBlockConfiguration, + ) -> Result<()> { + Err(Error::UnsupportedFeature( + "Bucket Public Access Block configuration is not supported by this object store".into(), + )) + } + + /// Remove the bucket's Public Access Block configuration. + /// + /// Returns success when the configuration is already absent. + /// + /// # Errors + /// + /// Returns `UnsupportedFeature` when the object store does not implement this operation. + async fn delete_bucket_public_access_block(&self, _bucket: &str) -> Result<()> { + Err(Error::UnsupportedFeature( + "Bucket Public Access Block removal is not supported by this object store".into(), + )) + } + /// Get bucket notification configuration as flat rules. async fn get_bucket_notifications(&self, bucket: &str) -> Result>; diff --git a/crates/s3/src/client.rs b/crates/s3/src/client.rs index 9ce099f..d4d9054 100644 --- a/crates/s3/src/client.rs +++ b/crates/s3/src/client.rs @@ -44,13 +44,14 @@ use rc_core::{ MultipartUploadListResult, NotificationTarget, ObjectAttributes, ObjectChecksum, ObjectEncryptionRequest, ObjectInfo, ObjectLockOptions, ObjectReadOptions, ObjectRetention, ObjectStore, ObjectTransferMetadata, ObjectVersion, ObjectVersionIdentifier, - ObjectVersionListResult, ObjectWriteEncryption, ObjectWriteOptions, RemotePath, - ReplicationCheckPhase, ReplicationCheckPhaseState, ReplicationCheckResult, - ReplicationCheckStatus, ReplicationConfiguration, ReplicationResyncStartOptions, - ReplicationResyncStartResult, ReplicationResyncState, ReplicationResyncStatus, - ReplicationResyncTargetStatus, RequestHeader, Result, RetentionDuration, RetentionDurationUnit, - RetentionMode, SelectOptions, SseCustomerKey, TransferCopyOptions, TransferReadOptions, - global_request_headers, is_retryable_error, retry_with_backoff, + ObjectVersionListResult, ObjectWriteEncryption, ObjectWriteOptions, + PublicAccessBlockConfiguration, RemotePath, ReplicationCheckPhase, ReplicationCheckPhaseState, + ReplicationCheckResult, ReplicationCheckStatus, ReplicationConfiguration, + ReplicationResyncStartOptions, ReplicationResyncStartResult, ReplicationResyncState, + ReplicationResyncStatus, ReplicationResyncTargetStatus, RequestHeader, Result, + RetentionDuration, RetentionDurationUnit, RetentionMode, SelectOptions, SseCustomerKey, + TransferCopyOptions, TransferReadOptions, global_request_headers, is_retryable_error, + retry_with_backoff, }; use reqwest::Method; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, LOCATION}; @@ -700,6 +701,12 @@ fn is_missing_cors_configuration_response( status_code.is_none_or(|status| status == 404) } +fn is_missing_public_access_block_configuration(error_text: &str) -> bool { + error_text + .to_ascii_lowercase() + .contains("nosuchpublicaccessblockconfiguration") +} + fn sdk_cors_rule_to_core(rule: &aws_sdk_s3::types::CorsRule) -> CorsRule { CorsRule { id: rule.id().map(str::to_string), @@ -7045,6 +7052,119 @@ impl ObjectStore for S3Client { } } + async fn get_bucket_public_access_block( + &self, + bucket: &str, + ) -> Result> { + let response = match self + .inner + .get_public_access_block() + .bucket(bucket) + .send() + .await + { + Ok(response) => response, + Err(error) => { + if let Some(auth_error) = crate::auth::sdk_temporary_credential_error(&error) { + return Err(auth_error); + } + if is_missing_public_access_block_configuration(&error.to_string()) { + return Ok(None); + } + let error_text = self.redact_sensitive_text(error.to_string()); + return Err(Error::General(format!( + "get_bucket_public_access_block: {error_text}" + ))); + } + }; + + let configuration = response + .public_access_block_configuration() + .ok_or_else(|| { + Error::General( + "get_bucket_public_access_block: response omitted configuration".to_string(), + ) + })?; + + Ok(Some(PublicAccessBlockConfiguration { + block_public_acls: configuration.block_public_acls().ok_or_else(|| { + Error::General( + "get_bucket_public_access_block: response omitted BlockPublicAcls".to_string(), + ) + })?, + ignore_public_acls: configuration.ignore_public_acls().ok_or_else(|| { + Error::General( + "get_bucket_public_access_block: response omitted IgnorePublicAcls".to_string(), + ) + })?, + block_public_policy: configuration.block_public_policy().ok_or_else(|| { + Error::General( + "get_bucket_public_access_block: response omitted BlockPublicPolicy" + .to_string(), + ) + })?, + restrict_public_buckets: configuration.restrict_public_buckets().ok_or_else(|| { + Error::General( + "get_bucket_public_access_block: response omitted RestrictPublicBuckets" + .to_string(), + ) + })?, + })) + } + + async fn set_bucket_public_access_block( + &self, + bucket: &str, + configuration: PublicAccessBlockConfiguration, + ) -> Result<()> { + let configuration = aws_sdk_s3::types::PublicAccessBlockConfiguration::builder() + .block_public_acls(configuration.block_public_acls) + .ignore_public_acls(configuration.ignore_public_acls) + .block_public_policy(configuration.block_public_policy) + .restrict_public_buckets(configuration.restrict_public_buckets) + .build(); + + self.inner + .put_public_access_block() + .bucket(bucket) + .public_access_block_configuration(configuration) + .send() + .await + .map_err(|error| { + if let Some(auth_error) = crate::auth::sdk_temporary_credential_error(&error) { + return auth_error; + } + let error_text = self.redact_sensitive_text(error.to_string()); + Error::General(format!("set_bucket_public_access_block: {error_text}")) + })?; + + Ok(()) + } + + async fn delete_bucket_public_access_block(&self, bucket: &str) -> Result<()> { + match self + .inner + .delete_public_access_block() + .bucket(bucket) + .send() + .await + { + Ok(_) => Ok(()), + Err(error) => { + if let Some(auth_error) = crate::auth::sdk_temporary_credential_error(&error) { + return Err(auth_error); + } + if is_missing_public_access_block_configuration(&error.to_string()) { + return Ok(()); + } + let error_text = self.redact_sensitive_text(error.to_string()); + Err(Error::General(format!( + "delete_bucket_public_access_block: {error_text}" + ))) + } + } + } + async fn get_bucket_cors(&self, bucket: &str) -> Result> { let response = match self.inner.get_bucket_cors().bucket(bucket).send().await { Ok(response) => response, @@ -9381,6 +9501,90 @@ mod tests { )); } + #[test] + fn missing_public_access_block_configuration_is_detected_by_service_code() { + assert!(is_missing_public_access_block_configuration( + "service error: NoSuchPublicAccessBlockConfiguration" + )); + assert!(!is_missing_public_access_block_configuration( + "AccessDenied" + )); + assert!(!is_missing_public_access_block_configuration( + "connection closed" + )); + } + + #[tokio::test] + async fn get_bucket_public_access_block_reads_all_four_flags() { + let response = http::Response::builder() + .status(200) + .body(SdkBody::from( + r#"truetruetruetrue"#, + )) + .expect("build PAB response"); + let (client, request_receiver) = test_s3_client(Some(response)); + + let result = client + .get_bucket_public_access_block("bucket") + .await + .expect("get bucket PAB") + .expect("PAB is configured"); + assert_eq!(result, PublicAccessBlockConfiguration::secure_default()); + + let request = request_receiver.expect_request(); + assert_eq!(request.method(), http::Method::GET); + assert!(request.uri().to_string().contains("?publicAccessBlock")); + } + + #[tokio::test] + async fn set_bucket_public_access_block_sends_all_four_flags() { + let response = http::Response::builder() + .status(200) + .body(SdkBody::from("")) + .expect("build PAB response"); + let (client, request_receiver) = test_s3_client(Some(response)); + + client + .set_bucket_public_access_block( + "bucket", + PublicAccessBlockConfiguration::secure_default(), + ) + .await + .expect("set bucket PAB"); + + let request = request_receiver.expect_request(); + assert_eq!(request.method(), http::Method::PUT); + assert!(request.uri().to_string().contains("?publicAccessBlock")); + let body = request.body().bytes().expect("request body bytes"); + let body = std::str::from_utf8(body).expect("request body is utf8"); + for field in [ + "true", + "true", + "true", + "true", + ] { + assert!(body.contains(field), "missing {field} from {body}"); + } + } + + #[tokio::test] + async fn delete_bucket_public_access_block_sends_delete_subresource() { + let response = http::Response::builder() + .status(204) + .body(SdkBody::from("")) + .expect("build delete PAB response"); + let (client, request_receiver) = test_s3_client(Some(response)); + + client + .delete_bucket_public_access_block("bucket") + .await + .expect("delete bucket PAB"); + + let request = request_receiver.expect_request(); + assert_eq!(request.method(), http::Method::DELETE); + assert!(request.uri().to_string().contains("?publicAccessBlock")); + } + #[test] fn sdk_cors_rule_to_core_drops_empty_optional_headers() { let sdk_rule = aws_sdk_s3::types::CorsRule::builder() diff --git a/docs/reference/rc/bucket.md b/docs/reference/rc/bucket.md index f866e48..129b106 100644 --- a/docs/reference/rc/bucket.md +++ b/docs/reference/rc/bucket.md @@ -2,7 +2,7 @@ ## Purpose -The `rc bucket` operation is the preferred noun-first entry point for bucket-oriented workflows. It groups bucket listing, creation, deletion, notification, CORS, encryption, versioning, Object Lock, quota, anonymous access, lifecycle, and replication operations under one command family. +The `rc bucket` operation is the preferred noun-first entry point for bucket-oriented workflows. It groups bucket listing, creation, deletion, notification, CORS, encryption, versioning, Object Lock, Public Access Block, quota, anonymous access, lifecycle, and replication operations under one command family. ## Syntax @@ -17,6 +17,7 @@ rc bucket encryption ... rc bucket version ... rc bucket lock ... rc bucket quota ... +rc bucket public-access-block ... rc bucket anonymous ... rc bucket lifecycle ... rc bucket replication ... @@ -35,6 +36,7 @@ rc bucket replication