diff --git a/README.md b/README.md index 6f31919..3ab0604 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ rc admin service-account create local/ AKIAIOSFODNN7EXAMPLE wJalrXUtnFEMI/K7MDEN # Create a service account with inline policy file rc admin service-account create local/ SAKEY123 SASECRET123 --policy ./service-account-policy.json +# Inspect any access key and resolve whether it belongs to a user, service account, or STS credential +rc admin access-key info local/ AKIAIOSFODNN7EXAMPLE +rc admin access-key info local/ AKIAIOSFODNN7EXAMPLE --json + # Manage bucket event notifications rc event add local/my-bucket arn:aws:sns:us-east-1:123456789012:topic --event 's3:ObjectCreated:*' rc event list local/my-bucket @@ -306,6 +310,7 @@ For full command documentation, see the [`rc` command reference](docs/reference/ | `admin policy` | Manage IAM policies (create, remove, list, info, attach) | | `admin group` | Manage IAM groups (add, remove, list, info, enable, disable, add-members, rm-members) | | `admin service-account` | Manage service accounts (create, remove, list, info) | +| `admin access-key` | Inspect access key identity and metadata (info) | | `admin info` | Display cluster information (cluster, server, disk) | | `admin heal` | Manage cluster healing operations (status, start, stop) | | `admin pool` | List pools and inspect expansion/decommission status | diff --git a/crates/cli/src/commands/admin/access_key.rs b/crates/cli/src/commands/admin/access_key.rs new file mode 100644 index 0000000..b5acf14 --- /dev/null +++ b/crates/cli/src/commands/admin/access_key.rs @@ -0,0 +1,182 @@ +//! Access key inspection commands. +//! +//! Commands for resolving an access key to its IAM identity type and metadata. + +use clap::Subcommand; + +use super::get_admin_client; +use crate::exit_code::ExitCode; +use crate::output::Formatter; +use rc_core::admin::{AccessKeyDetails, AccessKeyInfo, AdminApi, OpenIdAccessKeyInfo}; + +/// Access key inspection subcommands. +#[derive(Subcommand, Debug)] +pub enum AccessKeyCommands { + /// Get access key information + Info(InfoArgs), +} + +#[derive(clap::Args, Debug)] +pub struct InfoArgs { + /// Alias name of the server + pub alias: String, + + /// Access key to inspect + pub access_key: String, +} + +/// Execute an access key subcommand. +pub async fn execute(cmd: AccessKeyCommands, formatter: &Formatter) -> ExitCode { + match cmd { + AccessKeyCommands::Info(args) => execute_info(args, formatter).await, + } +} + +async fn execute_info(args: InfoArgs, formatter: &Formatter) -> ExitCode { + let client = match get_admin_client(&args.alias, formatter) { + Ok(c) => c, + Err(code) => return code, + }; + + match client.get_access_key_info(&args.access_key).await { + Ok(info) => { + if formatter.is_json() { + formatter.json(&info); + } else { + print_access_key_info(&info, formatter); + } + ExitCode::Success + } + Err(rc_core::Error::NotFound(_)) => { + formatter.error(&format!("Access key '{}' not found", args.access_key)); + ExitCode::NotFound + } + Err(e) if is_access_key_not_found_error(&e) => { + formatter.error(&format!("Access key '{}' not found", args.access_key)); + ExitCode::NotFound + } + Err(e) => formatter.fail( + ExitCode::GeneralError, + &format!("Failed to get access key info: {e}"), + ), + } +} + +fn is_access_key_not_found_error(error: &rc_core::Error) -> bool { + error.to_string().contains("access key not exist") +} + +fn print_access_key_info(info: &AccessKeyInfo, formatter: &Formatter) { + let styled_key = formatter.style_name(&info.access_key); + formatter.println(&format!("Access Key: {styled_key}")); + formatter.println(&format!("User Type: {}", info.user_type)); + formatter.println(&format!("Provider: {}", info.user_provider)); + + print_common_info(&info.info, formatter); + + if let Some(username) = &info.ldap_specific_info.username { + formatter.println(&format!("LDAP Username: {username}")); + } + + print_openid_info(&info.open_id_specific_info, formatter); +} + +fn print_common_info(info: &AccessKeyDetails, formatter: &Formatter) { + if let Some(parent) = &info.parent_user { + formatter.println(&format!("Parent User: {parent}")); + } + + if let Some(status) = &info.account_status { + formatter.println(&format!("Status: {status}")); + } + + if let Some(expiration) = &info.expiration { + formatter.println(&format!("Expiration: {expiration}")); + } + + if let Some(name) = &info.name { + formatter.println(&format!("Name: {name}")); + } + + if let Some(description) = &info.description { + formatter.println(&format!("Description: {description}")); + } + + if let Some(implied_policy) = info.implied_policy { + formatter.println(&format!("Implied Policy: {implied_policy}")); + } + + if let Some(policy) = &info.policy { + formatter.println(""); + formatter.println("Policy:"); + formatter.println(policy); + } +} + +fn print_openid_info(info: &OpenIdAccessKeyInfo, formatter: &Formatter) { + if let Some(config_name) = &info.config_name { + formatter.println(&format!("OpenID Config: {config_name}")); + } + + if let Some(user_id) = &info.user_id { + formatter.println(&format!("OpenID User: {user_id}")); + } + + if let Some(user_id_claim) = &info.user_id_claim { + formatter.println(&format!("User Claim: {user_id_claim}")); + } + + if let Some(display_name) = &info.display_name { + formatter.println(&format!("Display Name: {display_name}")); + } + + if let Some(display_name_claim) = &info.display_name_claim { + formatter.println(&format!("Display Claim: {display_name_claim}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rc_core::admin::{AccessKeyDetails, LdapAccessKeyInfo, OpenIdAccessKeyInfo}; + + #[test] + fn test_access_key_info_serializes_server_shape() { + let info = AccessKeyInfo { + access_key: "svc-ldap".to_string(), + user_type: "Service Account".to_string(), + user_provider: "ldap".to_string(), + info: AccessKeyDetails { + parent_user: Some("ldap-parent".to_string()), + account_status: Some("on".to_string()), + implied_policy: Some(false), + policy: Some("{\"Version\":\"2012-10-17\"}".to_string()), + expiration: None, + name: Some("LDAP Service".to_string()), + description: None, + }, + ldap_specific_info: LdapAccessKeyInfo { + username: Some("alice".to_string()), + }, + open_id_specific_info: OpenIdAccessKeyInfo::default(), + }; + + let value = serde_json::to_value(info).expect("serialize access key info"); + + assert_eq!(value["accessKey"], "svc-ldap"); + assert_eq!(value["userType"], "Service Account"); + assert_eq!(value["userProvider"], "ldap"); + assert_eq!(value["parentUser"], "ldap-parent"); + assert_eq!(value["accountStatus"], "on"); + assert_eq!(value["ldapSpecificInfo"]["username"], "alice"); + assert!(value.get("openIDSpecificInfo").is_none()); + assert!(value.get("openIdSpecificInfo").is_none()); + } + + #[test] + fn test_access_key_not_exist_error_maps_to_not_found() { + let error = rc_core::Error::General("Bad request: access key not exist".to_string()); + + assert!(is_access_key_not_found_error(&error)); + } +} diff --git a/crates/cli/src/commands/admin/mod.rs b/crates/cli/src/commands/admin/mod.rs index 4a55aa1..eee3a99 100644 --- a/crates/cli/src/commands/admin/mod.rs +++ b/crates/cli/src/commands/admin/mod.rs @@ -3,6 +3,7 @@ //! This module provides commands for managing users, policies, groups, //! service accounts, and cluster operations on RustFS/MinIO-compatible servers. +mod access_key; mod decommission; mod expand; mod group; @@ -63,6 +64,10 @@ pub enum AdminCommands { /// Manage service accounts #[command(name = "service-account", subcommand)] ServiceAccount(service_account::ServiceAccountCommands), + + /// Inspect access key identities + #[command(name = "access-key", subcommand)] + AccessKey(access_key::AccessKeyCommands), } /// Execute an admin subcommand @@ -84,6 +89,9 @@ pub async fn execute(cmd: AdminCommands, output_config: OutputConfig) -> ExitCod AdminCommands::Policy(policy_cmd) => policy::execute(policy_cmd, &formatter).await, AdminCommands::Group(group_cmd) => group::execute(group_cmd, &formatter).await, AdminCommands::ServiceAccount(sa_cmd) => service_account::execute(sa_cmd, &formatter).await, + AdminCommands::AccessKey(access_key_cmd) => { + access_key::execute(access_key_cmd, &formatter).await + } } } @@ -154,6 +162,20 @@ mod tests { } } + #[test] + fn test_parse_admin_access_key_info() { + let cli = + TestCli::parse_from(["rc", "access-key", "info", "local", "AKIAIOSFODNN7EXAMPLE"]); + + match cli.command { + AdminCommands::AccessKey(access_key::AccessKeyCommands::Info(args)) => { + assert_eq!(args.alias, "local"); + assert_eq!(args.access_key, "AKIAIOSFODNN7EXAMPLE"); + } + _ => panic!("Unexpected command parsing result"), + } + } + #[test] fn test_parse_admin_heal_start_options() { let cli = TestCli::parse_from([ diff --git a/crates/cli/tests/help_contract.rs b/crates/cli/tests/help_contract.rs index 0345980..06cf34e 100644 --- a/crates/cli/tests/help_contract.rs +++ b/crates/cli/tests/help_contract.rs @@ -166,6 +166,7 @@ fn top_level_command_help_contract() { "policy", "group", "service-account", + "access-key", ], }, HelpCase { @@ -760,6 +761,11 @@ fn nested_subcommand_help_contract() { usage: "Usage: rc admin service-account rm [OPTIONS] ", expected_tokens: &[], }, + HelpCase { + args: &["admin", "access-key", "info"], + usage: "Usage: rc admin access-key info [OPTIONS] ", + expected_tokens: &[], + }, HelpCase { args: &["version", "enable"], usage: "Usage: rc version enable [OPTIONS] ", diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 6b4c77d..18c716d 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -18,8 +18,9 @@ pub use tier::{ TierRustFS, TierS3, TierTencent, TierType, }; pub use types::{ - BucketQuota, CreateServiceAccountRequest, Group, GroupStatus, Policy, PolicyEntity, PolicyInfo, - ServiceAccount, ServiceAccountCreateResponse, ServiceAccountCredentials, SetPolicyRequest, + AccessKeyDetails, AccessKeyInfo, BucketQuota, CreateServiceAccountRequest, Group, GroupStatus, + LdapAccessKeyInfo, OpenIdAccessKeyInfo, Policy, PolicyEntity, PolicyInfo, ServiceAccount, + ServiceAccountCreateResponse, ServiceAccountCredentials, SetPolicyRequest, UpdateGroupMembersRequest, User, UserStatus, }; @@ -156,6 +157,9 @@ pub trait AdminApi: Send + Sync { /// Delete a service account async fn delete_service_account(&self, access_key: &str) -> Result<()>; + /// Get information for any access key type. + async fn get_access_key_info(&self, access_key: &str) -> Result; + // ==================== Bucket Quota Operations ==================== /// Set bucket quota in bytes diff --git a/crates/core/src/admin/types.rs b/crates/core/src/admin/types.rs index 23d1df5..7c4edbc 100644 --- a/crates/core/src/admin/types.rs +++ b/crates/core/src/admin/types.rs @@ -236,6 +236,98 @@ impl ServiceAccount { } } +/// Identity-specific LDAP access key details. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LdapAccessKeyInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +impl LdapAccessKeyInfo { + pub fn is_empty(&self) -> bool { + self.username.is_none() + } +} + +/// Identity-specific OpenID access key details. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OpenIdAccessKeyInfo { + #[serde(rename = "configName", skip_serializing_if = "Option::is_none")] + pub config_name: Option, + + #[serde(rename = "userID", skip_serializing_if = "Option::is_none")] + pub user_id: Option, + + #[serde(rename = "userIDClaim", skip_serializing_if = "Option::is_none")] + pub user_id_claim: Option, + + #[serde(rename = "displayName", skip_serializing_if = "Option::is_none")] + pub display_name: Option, + + #[serde(rename = "displayNameClaim", skip_serializing_if = "Option::is_none")] + pub display_name_claim: Option, +} + +impl OpenIdAccessKeyInfo { + pub fn is_empty(&self) -> bool { + self.config_name.is_none() + && self.user_id.is_none() + && self.user_id_claim.is_none() + && self.display_name.is_none() + && self.display_name_claim.is_none() + } +} + +/// Common details shared by users, service accounts, and STS credentials. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccessKeyDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_user: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub account_status: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub implied_policy: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub policy: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration: Option, +} + +/// General access key information returned by the RustFS Admin API. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccessKeyInfo { + pub access_key: String, + pub user_type: String, + pub user_provider: String, + + #[serde(flatten)] + pub info: AccessKeyDetails, + + #[serde(default, skip_serializing_if = "LdapAccessKeyInfo::is_empty")] + pub ldap_specific_info: LdapAccessKeyInfo, + + #[serde( + rename = "openIDSpecificInfo", + default, + skip_serializing_if = "OpenIdAccessKeyInfo::is_empty" + )] + pub open_id_specific_info: OpenIdAccessKeyInfo, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServiceAccountCreateResponse { @@ -528,6 +620,53 @@ mod tests { ); } + #[test] + fn test_access_key_info_deserializes_openid_server_shape() { + let value = serde_json::json!({ + "accessKey": "sts-openid", + "userType": "STS", + "userProvider": "openid", + "parentUser": "openid-parent", + "accountStatus": "on", + "openIDSpecificInfo": { + "configName": "dex", + "userID": "subject-123", + "userIDClaim": "sub", + "displayName": "RustFS User", + "displayNameClaim": "name" + } + }); + + let info: AccessKeyInfo = + serde_json::from_value(value).expect("deserialize access key info"); + + assert_eq!(info.access_key, "sts-openid"); + assert_eq!(info.user_type, "STS"); + assert_eq!(info.user_provider, "openid"); + assert_eq!(info.info.parent_user.as_deref(), Some("openid-parent")); + assert_eq!(info.info.account_status.as_deref(), Some("on")); + assert_eq!( + info.open_id_specific_info.config_name.as_deref(), + Some("dex") + ); + assert_eq!( + info.open_id_specific_info.user_id.as_deref(), + Some("subject-123") + ); + assert_eq!( + info.open_id_specific_info.user_id_claim.as_deref(), + Some("sub") + ); + assert_eq!( + info.open_id_specific_info.display_name.as_deref(), + Some("RustFS User") + ); + assert_eq!( + info.open_id_specific_info.display_name_claim.as_deref(), + Some("name") + ); + } + #[test] fn test_bucket_quota_serialization() { let quota = BucketQuota { diff --git a/crates/s3/src/admin.rs b/crates/s3/src/admin.rs index a05fc18..74895dc 100644 --- a/crates/s3/src/admin.rs +++ b/crates/s3/src/admin.rs @@ -10,9 +10,9 @@ use aws_sigv4::http_request::{ }; use aws_sigv4::sign::v4; use rc_core::admin::{ - AdminApi, BucketQuota, ClusterInfo, CreateServiceAccountRequest, Group, GroupStatus, - HealScanMode, HealStartRequest, HealStatus, Policy, PolicyEntity, PolicyInfo, PoolStatus, - PoolTarget, RebalanceStartResult, RebalanceStatus, ServiceAccount, + AccessKeyInfo, AdminApi, BucketQuota, ClusterInfo, CreateServiceAccountRequest, Group, + GroupStatus, HealScanMode, HealStartRequest, HealStatus, Policy, PolicyEntity, PolicyInfo, + PoolStatus, PoolTarget, RebalanceStartResult, RebalanceStatus, ServiceAccount, ServiceAccountCreateResponse, UpdateGroupMembersRequest, User, UserStatus, }; use rc_core::{Alias, Error, Result}; @@ -856,6 +856,12 @@ impl AdminApi for AdminClient { .await } + async fn get_access_key_info(&self, access_key: &str) -> Result { + let query = [("accessKey", access_key)]; + self.request(Method::GET, "/info-access-key", Some(&query), None) + .await + } + // ==================== Bucket Quota Operations ==================== async fn set_bucket_quota(&self, bucket: &str, quota: u64) -> Result { @@ -1232,6 +1238,37 @@ mod tests { handle.join().expect("server thread should finish"); } + #[tokio::test] + async fn test_get_access_key_info_uses_info_access_key_endpoint() { + let (endpoint, receiver, handle) = start_admin_test_server( + "200 OK", + r#"{"accessKey":"svc-ldap","userType":"Service Account","userProvider":"ldap","parentUser":"ldap-parent","accountStatus":"on","ldapSpecificInfo":{"username":"alice"}}"#, + ); + let client = admin_client_for_endpoint(&endpoint); + + let info = client + .get_access_key_info("svc-ldap") + .await + .expect("access key info request"); + + assert_eq!(info.access_key, "svc-ldap"); + assert_eq!(info.user_type, "Service Account"); + assert_eq!(info.user_provider, "ldap"); + assert_eq!(info.info.parent_user.as_deref(), Some("ldap-parent")); + assert_eq!(info.info.account_status.as_deref(), Some("on")); + assert_eq!(info.ldap_specific_info.username.as_deref(), Some("alice")); + assert!(info.open_id_specific_info.is_empty()); + + let request = receiver.recv().expect("captured request"); + assert_eq!(request.method, "GET"); + assert_eq!( + request.target, + "/rustfs/admin/v3/info-access-key?accessKey=svc-ldap" + ); + assert!(request.body.is_empty()); + handle.join().expect("server thread should finish"); + } + #[tokio::test] async fn test_anonymous_admin_requests_skip_authorization_header() { let (endpoint, receiver, handle) =