diff --git a/libs/ferriskey-cli-core/src/lib.rs b/libs/ferriskey-cli-core/src/lib.rs index cb09b7c..bc86b99 100644 --- a/libs/ferriskey-cli-core/src/lib.rs +++ b/libs/ferriskey-cli-core/src/lib.rs @@ -1,6 +1,7 @@ mod client; mod config; mod context; +mod realm; use config::StoredContext; use ferriskey_commands::{Cli, Commands}; @@ -14,6 +15,8 @@ pub enum CliCoreError { Client(#[from] client::ClientCommandError), #[error(transparent)] Context(#[from] context::ContextCommandError), + #[error(transparent)] + Realm(#[from] realm::RealmCommandError), #[error("command '{0}' is not implemented yet")] UnimplementedCommand(&'static str), } @@ -22,7 +25,12 @@ pub fn run(cli: Cli) -> Result<()> { let inline_context = build_inline_context(&cli); match cli.command { Commands::Context(command) => Ok(context::run(cli.output.as_str(), command)?), - Commands::Realm(_) => Err(CliCoreError::UnimplementedCommand("realm")), + Commands::Realm(command) => Ok(realm::run( + cli.output.as_str(), + cli.context.as_deref(), + inline_context, + command, + )?), Commands::Client(command) => Ok(client::run( cli.output.as_str(), cli.context.as_deref(), diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs new file mode 100644 index 0000000..543ca8e --- /dev/null +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -0,0 +1,260 @@ +use ferriskey_client::{CreateRealmRequest, FerriskeyClient, FerriskeyClientError, Realm}; +use ferriskey_commands::{RealmCommand, RealmNameArgs, RealmSubcommand}; +use serde::Serialize; +use thiserror::Error; + +use crate::config::{ConfigError, FileContextRepository, StoredContext}; + +type Result = std::result::Result; + +pub fn run( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + command: RealmCommand, +) -> Result<()> { + match command.command { + RealmSubcommand::List => list_realms(output_format, context_override, inline_context), + RealmSubcommand::Get(args) => get_realm(output_format, context_override, inline_context, args), + RealmSubcommand::Create(args) => { + create_realm(output_format, context_override, inline_context, args) + } + RealmSubcommand::Delete(args) => { + delete_realm(output_format, context_override, inline_context, args) + } + } +} + +#[derive(Debug, Error)] +pub enum RealmCommandError { + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Api(#[from] FerriskeyClientError), + #[error("context '{0}' does not exist")] + ContextNotFound(String), + #[error("no active context is configured")] + NoActiveContext, + #[error( + "auth realm is required: pass '--realm' or configure a default realm on the selected context" + )] + MissingAuthRealm, + #[error("realm '{0}' not found")] + RealmNotFound(String), + #[error("unsupported output format: {0}")] + UnsupportedOutputFormat(String), + #[error("failed to serialize JSON output")] + SerializeJson { + #[source] + source: serde_json::Error, + }, + #[error("failed to serialize YAML output")] + SerializeYaml { + #[source] + source: serde_yaml::Error, + }, +} + +#[derive(Debug, Serialize)] +struct RealmView { + id: String, + name: String, +} + +fn resolve_context( + context_override: Option<&str>, + inline_context: Option, +) -> Result { + if let Some(ctx) = inline_context { + return Ok(ctx); + } + let repository = FileContextRepository::new()?; + let store = repository.load()?; + let context_name = match context_override { + Some(name) => name.to_owned(), + None => store + .current_context + .clone() + .ok_or(RealmCommandError::NoActiveContext)?, + }; + store + .contexts + .get(&context_name) + .cloned() + .ok_or(RealmCommandError::ContextNotFound(context_name)) +} + +fn auth_client(context: &StoredContext) -> Result { + let auth_realm = context + .realm + .as_deref() + .ok_or(RealmCommandError::MissingAuthRealm)?; + let unauthenticated = FerriskeyClient::new(context.url.clone(), "", "")?; + let token = unauthenticated.exchange_client_credentials( + auth_realm, + context.client_id.as_str(), + context.client_secret.as_str(), + )?; + Ok(FerriskeyClient::new( + context.url.clone(), + "", + token.access_token, + )?) +} + +fn to_view(realm: Realm) -> RealmView { + RealmView { + id: realm.id, + name: realm.name, + } +} + +fn list_realms( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let auth_realm = context + .realm + .clone() + .ok_or(RealmCommandError::MissingAuthRealm)?; + let client = auth_client(&context)?; + let realms = client.list_realms(&auth_realm)?; + let views: Vec = realms.into_iter().map(to_view).collect(); + render_realm_list(output_format, &views) +} + +fn get_realm( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: RealmNameArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let client = auth_client(&context)?; + let realm = client.get_realm(&args.name)?; + render_realm(output_format, to_view(realm)) +} + +fn create_realm( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: RealmNameArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let client = auth_client(&context)?; + let request = CreateRealmRequest { name: args.name }; + let realm = client.create_realm(&request)?; + render_realm(output_format, to_view(realm)) +} + +fn delete_realm( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: RealmNameArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let client = auth_client(&context)?; + client.delete_realm(&args.name)?; + render_message(output_format, &format!("realm '{}' deleted", args.name)) +} + +fn render_realm_list(output_format: &str, realms: &[RealmView]) -> Result<()> { + match output_format { + "table" => { + let name_width = realms + .iter() + .map(|r| r.name.len()) + .max() + .unwrap_or(0) + .max("NAME".len()); + let id_width = realms + .iter() + .map(|r| r.id.len()) + .max() + .unwrap_or(0) + .max("ID".len()); + + println!("{: { + println!( + "{}", + serde_json::to_string_pretty(realms) + .map_err(|source| RealmCommandError::SerializeJson { source })? + ); + Ok(()) + } + "yaml" => { + println!( + "{}", + serde_yaml::to_string(realms) + .map_err(|source| RealmCommandError::SerializeYaml { source })? + ); + Ok(()) + } + _ => Err(RealmCommandError::UnsupportedOutputFormat( + output_format.to_owned(), + )), + } +} + +fn render_realm(output_format: &str, realm: RealmView) -> Result<()> { + match output_format { + "table" => { + println!("id: {}", realm.id); + println!("name: {}", realm.name); + Ok(()) + } + "json" => { + println!( + "{}", + serde_json::to_string_pretty(&realm) + .map_err(|source| RealmCommandError::SerializeJson { source })? + ); + Ok(()) + } + "yaml" => { + println!( + "{}", + serde_yaml::to_string(&realm) + .map_err(|source| RealmCommandError::SerializeYaml { source })? + ); + Ok(()) + } + _ => Err(RealmCommandError::UnsupportedOutputFormat( + output_format.to_owned(), + )), + } +} + +fn render_message(output_format: &str, message: &str) -> Result<()> { + match output_format { + "table" => { + println!("{message}"); + Ok(()) + } + "json" => { + println!("{}", serde_json::json!({ "message": message })); + Ok(()) + } + "yaml" => { + println!( + "{}", + serde_yaml::to_string(&serde_json::json!({ "message": message })) + .map_err(|source| RealmCommandError::SerializeYaml { source })? + ); + Ok(()) + } + _ => Err(RealmCommandError::UnsupportedOutputFormat( + output_format.to_owned(), + )), + } +} diff --git a/libs/ferriskey-commands/src/lib.rs b/libs/ferriskey-commands/src/lib.rs index 6c7761a..8e5ee87 100644 --- a/libs/ferriskey-commands/src/lib.rs +++ b/libs/ferriskey-commands/src/lib.rs @@ -9,6 +9,7 @@ pub use self::client::{ pub use self::context::{ ContextAddArgs, ContextCommand, ContextRemoveArgs, ContextSubcommand, ContextUseArgs, }; +pub use self::realm::{RealmCommand, RealmNameArgs, RealmSubcommand}; use clap::{Parser, Subcommand}; /// FerrisKey CLI. @@ -16,27 +17,27 @@ use clap::{Parser, Subcommand}; #[command(name = "ferriskey", about = "FerrisKey CLI")] pub struct Cli { /// Override the active context for this command. - #[arg(long)] + #[arg(long, global = true)] pub context: Option, /// Output format. - #[arg(long, short = 'o', value_parser = ["table", "json", "yaml"], default_value = "table")] + #[arg(long, short = 'o', global = true, value_parser = ["table", "json", "yaml"], default_value = "table")] pub output: String, /// FerrisKey server URL (overrides context file). - #[arg(long, env = "FERRISKEY_URL")] + #[arg(long, global = true, env = "FERRISKEY_URL")] pub url: Option, /// Client ID used for authentication (overrides context file). - #[arg(long, env = "FERRISKEY_CLIENT_ID")] + #[arg(long, global = true, env = "FERRISKEY_CLIENT_ID")] pub client_id: Option, /// Client secret used for authentication (overrides context file). - #[arg(long, env = "FERRISKEY_CLIENT_SECRET")] + #[arg(long, global = true, env = "FERRISKEY_CLIENT_SECRET")] pub client_secret: Option, /// Default realm (overrides context file). - #[arg(long, env = "FERRISKEY_REALM")] + #[arg(long, global = true, env = "FERRISKEY_REALM")] pub realm: Option, /// Command to execute.