Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ 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

# Update selected fields on an existing service account
rc admin service-account update local/ SAKEY123 --policy ./service-account-policy.json --description "Automation access"

# 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
Expand Down Expand Up @@ -345,7 +348,7 @@ For full command documentation, see the [`rc` command reference](docs/reference/
| `admin user` | Manage IAM users (add, remove, list, info, enable, disable) |
| `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 service-account` | Manage service accounts (create, update, 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) |
Expand Down
165 changes: 163 additions & 2 deletions crates/cli/src/commands/admin/service_account.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
//! Service account management commands
//!
//! Commands for managing service accounts: list, create, info, remove.
//! Commands for managing service accounts: list, create, update, info, remove.

use clap::Subcommand;
use serde::Serialize;

use super::get_admin_client;
use crate::exit_code::ExitCode;
use crate::output::Formatter;
use rc_core::admin::{AdminApi, CreateServiceAccountRequest, ServiceAccount};
use rc_core::admin::{
AdminApi, CreateServiceAccountRequest, ServiceAccount, UpdateServiceAccountRequest,
};

const DEFAULT_SERVICE_ACCOUNT_EXPIRY: &str = "9999-12-31T23:59:59Z";

Expand All @@ -22,6 +24,10 @@ pub enum ServiceAccountCommands {
/// Create a new service account
Create(CreateArgs),

/// Update an existing service account
#[command(aliases = ["edit", "set"])]
Update(UpdateArgs),

/// Get service account information
Info(InfoArgs),

Expand Down Expand Up @@ -68,6 +74,39 @@ pub struct CreateArgs {
pub expiry: Option<String>,
}

#[derive(clap::Args, Debug)]
pub struct UpdateArgs {
/// Alias name of the server
pub alias: String,

/// Access key of the service account
pub access_key: String,

/// Replacement secret key
#[arg(long)]
pub secret_key: Option<String>,

/// Replacement name for the service account
#[arg(long)]
pub name: Option<String>,

/// Replacement description
#[arg(long)]
pub description: Option<String>,

/// Replacement policy document (JSON file path)
#[arg(long)]
pub policy: Option<String>,

/// Replacement expiration time (ISO 8601 format)
#[arg(long)]
pub expiry: Option<String>,

/// Replacement account status
#[arg(long, value_parser = ["enabled", "disabled"])]
pub status: Option<String>,
}

#[derive(clap::Args, Debug)]
pub struct InfoArgs {
/// Alias name of the server
Expand Down Expand Up @@ -142,6 +181,7 @@ pub async fn execute(cmd: ServiceAccountCommands, formatter: &Formatter) -> Exit
match cmd {
ServiceAccountCommands::List(args) => execute_list(args, formatter).await,
ServiceAccountCommands::Create(args) => execute_create(args, formatter).await,
ServiceAccountCommands::Update(args) => execute_update(args, formatter).await,
ServiceAccountCommands::Info(args) => execute_info(args, formatter).await,
ServiceAccountCommands::Remove(args) => execute_remove(args, formatter).await,
}
Expand Down Expand Up @@ -272,6 +312,78 @@ fn should_retry_with_default_expiry(args: &CreateArgs, error: &rc_core::Error) -
text.contains("missing field `expiration`") || text.contains("InvalidExpiration")
}

async fn execute_update(args: UpdateArgs, formatter: &Formatter) -> ExitCode {
let policy = if let Some(policy_path) = &args.policy {
match std::fs::read_to_string(policy_path) {
Ok(content) => Some(content),
Err(e) => {
formatter.error(&format!(
"Failed to read policy file '{}': {e}",
policy_path
));
return ExitCode::UsageError;
}
}
} else {
None
};

let request = build_update_service_account_request(&args, policy);
if request.is_empty() {
formatter.error("Service account update requires at least one field");
return ExitCode::UsageError;
}

let client = match get_admin_client(&args.alias, formatter) {
Ok(c) => c,
Err(code) => return code,
};

match client
.update_service_account(&args.access_key, request)
.await
{
Ok(()) => {
let output = ServiceAccountOperationOutput {
success: true,
access_key: args.access_key.clone(),
message: format!("Service account '{}' updated successfully", args.access_key),
};
if formatter.is_json() {
formatter.json(&output);
} else {
let styled_key = formatter.style_name(&args.access_key);
formatter.success(&format!(
"Service account '{styled_key}' updated successfully."
));
}
ExitCode::Success
}
Err(rc_core::Error::NotFound(_)) => {
formatter.error(&format!("Service account '{}' not found", args.access_key));
ExitCode::NotFound
}
Err(e) => {
formatter.error(&format!("Failed to update service account: {e}"));
ExitCode::GeneralError
}
}
}

fn build_update_service_account_request(
args: &UpdateArgs,
policy: Option<String>,
) -> UpdateServiceAccountRequest {
UpdateServiceAccountRequest {
new_policy: policy,
new_secret_key: args.secret_key.clone(),
new_status: args.status.clone(),
new_name: args.name.clone(),
new_description: args.description.clone(),
new_expiration: args.expiry.clone(),
}
}

async fn execute_info(args: InfoArgs, formatter: &Formatter) -> ExitCode {
let client = match get_admin_client(&args.alias, formatter) {
Ok(c) => c,
Expand Down Expand Up @@ -411,6 +523,55 @@ mod tests {
assert_eq!(request.policy.as_deref(), Some("{\"x\":1}"));
}

#[test]
fn test_build_update_request_keeps_only_explicit_fields() {
let args = UpdateArgs {
alias: "local".to_string(),
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: None,
name: Some("automation-key".to_string()),
description: Some("Updated description".to_string()),
policy: Some("policy.json".to_string()),
expiry: None,
status: Some("enabled".to_string()),
};

let request = build_update_service_account_request(
&args,
Some("{\"Version\":\"2012-10-17\"}".to_string()),
);
assert_eq!(
request.new_policy.as_deref(),
Some("{\"Version\":\"2012-10-17\"}")
);
assert!(request.new_secret_key.is_none());
assert_eq!(request.new_name.as_deref(), Some("automation-key"));
assert_eq!(
request.new_description.as_deref(),
Some("Updated description")
);
assert!(request.new_expiration.is_none());
assert_eq!(request.new_status.as_deref(), Some("enabled"));
assert!(!request.is_empty());
}

#[test]
fn test_build_update_request_detects_empty_update() {
let args = UpdateArgs {
alias: "local".to_string(),
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: None,
name: None,
description: None,
policy: None,
expiry: None,
status: None,
};

let request = build_update_service_account_request(&args, None);
assert!(request.is_empty());
}

#[test]
fn test_should_retry_with_default_expiry_for_missing_field_error() {
let args = CreateArgs {
Expand Down
100 changes: 100 additions & 0 deletions crates/cli/tests/admin_service_account.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#![cfg(not(windows))]

mod admin_support;

use std::fs;
use std::process::Command;
use std::time::Duration;

use admin_support::{rc_binary, rc_host_alias, start_admin_test_server};

#[test]
fn service_account_update_dispatches_to_update_endpoint() {
let config_dir = tempfile::tempdir().expect("create config dir");
let policy_dir = tempfile::tempdir().expect("create policy dir");
let policy_path = policy_dir.path().join("policy.json");
fs::write(&policy_path, r#"{"Version":"2012-10-17","Statement":[]}"#)
.expect("write policy file");
let (endpoint, receiver, handle) = start_admin_test_server("");

let output = Command::new(rc_binary())
.args([
"--json",
"admin",
"service-account",
"update",
"myalias",
"service-key",
"--policy",
policy_path.to_str().expect("UTF-8 policy path"),
"--description",
"Updated description",
"--secret-key",
"replacement-secret",
])
.env("RC_CONFIG_DIR", config_dir.path())
.env("RC_HOST_myalias", rc_host_alias(&endpoint))
.output()
.expect("run rc command");

assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert!(!stdout.contains("replacement-secret"));
let payload: serde_json::Value = serde_json::from_str(&stdout).expect("JSON output");
assert_eq!(payload["success"], true);
assert_eq!(payload["access_key"], "service-key");

let request = receiver
.recv_timeout(Duration::from_secs(5))
.expect("captured admin request");
assert_eq!(request.method, "POST");
assert_eq!(
request.target,
"/rustfs/admin/v3/update-service-account?accessKey=service-key"
);
handle.join().expect("admin test server finished");
}

#[test]
fn service_account_update_requires_at_least_one_field() {
let config_dir = tempfile::tempdir().expect("create config dir");
let output = Command::new(rc_binary())
.args([
"admin",
"service-account",
"update",
"myalias",
"service-key",
])
.env("RC_CONFIG_DIR", config_dir.path())
.output()
.expect("run rc command");

assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stderr).contains("at least one field"));
}

#[test]
fn service_account_update_rejects_missing_policy_file() {
let config_dir = tempfile::tempdir().expect("create config dir");
let output = Command::new(rc_binary())
.args([
"admin",
"service-account",
"update",
"myalias",
"service-key",
"--policy",
"/definitely/not/a/policy.json",
])
.env("RC_CONFIG_DIR", config_dir.path())
.output()
.expect("run rc command");

assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stderr).contains("Failed to read policy file"));
}
12 changes: 12 additions & 0 deletions crates/cli/tests/help_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,18 @@ fn nested_subcommand_help_contract() {
usage: "Usage: rc admin service-account create [OPTIONS] <ALIAS> <ACCESS_KEY> <SECRET_KEY>",
expected_tokens: &["--name", "--description", "--policy", "--expiry"],
},
HelpCase {
args: &["admin", "service-account", "update"],
usage: "Usage: rc admin service-account update [OPTIONS] <ALIAS> <ACCESS_KEY>",
expected_tokens: &[
"--secret-key",
"--name",
"--description",
"--policy",
"--expiry",
"--status",
],
},
HelpCase {
args: &["admin", "service-account", "info"],
usage: "Usage: rc admin service-account info [OPTIONS] <ALIAS> <ACCESS_KEY>",
Expand Down
9 changes: 8 additions & 1 deletion crates/core/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub use types::{
AccessKeyDetails, AccessKeyInfo, BucketQuota, CreateServiceAccountRequest, Group, GroupStatus,
LdapAccessKeyInfo, OpenIdAccessKeyInfo, Policy, PolicyEntity, PolicyInfo, ServiceAccount,
ServiceAccountCreateResponse, ServiceAccountCredentials, SetPolicyRequest,
UpdateGroupMembersRequest, User, UserStatus,
UpdateGroupMembersRequest, UpdateServiceAccountRequest, User, UserStatus,
};

use async_trait::async_trait;
Expand Down Expand Up @@ -170,6 +170,13 @@ pub trait AdminApi: Send + Sync {
request: CreateServiceAccountRequest,
) -> Result<ServiceAccount>;

/// Update an existing service account
async fn update_service_account(
&self,
access_key: &str,
request: UpdateServiceAccountRequest,
) -> Result<()>;

/// Delete a service account
async fn delete_service_account(&self, access_key: &str) -> Result<()>;

Expand Down
Loading