use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use uuid::Uuid;
use crate::output::Format;
#[derive(Debug, Parser)]
#[command(
name = "iam",
version,
about = "Silicon IAM from the command line",
long_about = "Silicon IAM from the command line.\n\n\
Sign in once with `iam login`; the session is stored under \
~/.silicon-iam/ and renewed automatically. Most commands act on an \
organization: pass --org, or set a default with \
`iam config set org <handle>`.",
propagate_version = true,
disable_help_subcommand = false
)]
pub struct Cli {
#[command(flatten)]
pub global: Global,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args)]
pub struct Global {
#[arg(long, global = true, env = "SILICON_IAM_URL")]
pub url: Option<String>,
#[arg(long, global = true, env = "SILICON_IAM_PROFILE")]
pub profile: Option<String>,
#[arg(long, global = true, env = "SILICON_IAM_ORG")]
pub org: Option<String>,
#[arg(
long,
global = true,
env = "SILICON_IAM_TEST",
value_name = "ENVIRONMENT_ID",
value_parser = parse_testing_environment_id
)]
pub test: Option<Uuid>,
#[arg(long, global = true)]
pub step_up: Option<String>,
#[arg(long, short = 'o', global = true, value_enum, default_value_t = Format::Text)]
pub output: Format,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Login(LoginArgs),
SiliconLogin(SiliconLoginArgs),
Logout,
Whoami,
Signup(SignupArgs),
Commands,
#[command(subcommand)]
Org(OrgCommand),
#[command(subcommand)]
Member(MemberCommand),
#[command(subcommand)]
Invite(InviteCommand),
#[command(subcommand)]
Tag(TagCommand),
#[command(subcommand)]
Trust(TrustCommand),
#[command(subcommand)]
Approval(ApprovalCommand),
#[command(subcommand)]
Silicon(SiliconCommand),
#[command(subcommand)]
App(AppCommand),
#[command(subcommand)]
Env(EnvCommand),
#[command(subcommand)]
Session(SessionCommand),
#[command(subcommand)]
Config(ConfigCommand),
#[command(subcommand)]
System(SystemCommand),
}
#[derive(Debug, Args)]
pub struct LoginArgs {
#[arg(long, group = "identity")]
pub email: Option<String>,
#[arg(long, group = "identity")]
pub phone: Option<String>,
#[arg(long, group = "identity")]
pub carbon_id: Option<String>,
#[arg(long)]
pub code: Option<String>,
#[arg(long = "app-id", value_name = "APP_ID")]
pub app_id: Option<String>,
}
#[derive(Debug, Args)]
pub struct SiliconLoginArgs {
#[arg(long = "sid")]
pub sid: Option<String>,
#[arg(long = "stk")]
pub stk: Option<String>,
#[arg(long = "app-id", value_name = "APP_ID")]
pub app_id: Option<String>,
}
#[derive(Debug, Args)]
pub struct SignupArgs {
#[arg(long)]
pub email: String,
#[arg(long)]
pub phone: String,
#[arg(long)]
pub carbon_id: String,
#[arg(long)]
pub display_name: Option<String>,
#[arg(long)]
pub timezone: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum OrgCommand {
List(PageArgs),
Create {
handle: String,
#[arg(long)]
name: String,
#[arg(long)]
description: Option<String>,
},
Show {
handle: Option<String>,
},
Update {
handle: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
join_method: Option<String>,
},
Available {
handle: String,
},
Transfer {
membership_id: Uuid,
#[arg(long)]
org: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum MemberCommand {
List {
#[arg(long, value_name = "carbon|silicon")]
principal_type: Option<String>,
#[arg(long)]
tag: Option<Uuid>,
#[arg(long)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Show {
membership_id: Uuid,
},
Authorization {
membership_id: Uuid,
},
Update {
membership_id: Uuid,
#[arg(long)]
reports_to: Option<Uuid>,
#[arg(long)]
profile_photo: Option<String>,
},
Remove {
membership_id: Uuid,
#[arg(long)]
reassign_reports_to: Option<Uuid>,
},
Promote {
membership_id: Uuid,
},
Demote {
membership_id: Uuid,
},
Capabilities {
membership_id: Uuid,
#[arg(long = "capability", value_name = "CAPABILITY")]
capabilities: Vec<String>,
},
Directory {
#[arg(long)]
fields: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Self_ {
#[arg(long)]
fields: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum InviteCommand {
List {
#[arg(long)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
#[arg(long, group = "identity")]
carbon_id: Option<String>,
#[arg(long, group = "identity")]
email: Option<String>,
#[arg(long)]
job_role: String,
#[arg(long, default_value = "internal")]
boundary: String,
#[arg(long, default_value = "not_trusted")]
level: String,
},
Show {
invite_id: Uuid,
},
Revoke {
invite_id: Uuid,
},
Code {
email: String,
},
Accept {
invite_id: Uuid,
#[arg(long)]
code: String,
},
}
#[derive(Debug, Subcommand)]
pub enum TagCommand {
List(PageArgs),
Create {
name: String,
},
Show {
tag_id: Uuid,
},
Rename {
tag_id: Uuid,
name: String,
},
Delete {
tag_id: Uuid,
},
Members {
tag_id: Uuid,
#[command(flatten)]
page: PageArgs,
},
}
#[derive(Debug, Subcommand)]
pub enum TrustCommand {
Default,
SetDefault {
#[arg(long)]
boundary: String,
#[arg(long)]
level: String,
},
List(PageArgs),
Create {
#[arg(long, group = "subject")]
subject_tag: Option<Uuid>,
#[arg(long, group = "subject")]
subject_membership: Option<Uuid>,
#[arg(long, group = "target")]
target_tag: Option<Uuid>,
#[arg(long, group = "target")]
target_membership: Option<Uuid>,
#[arg(long)]
boundary: String,
#[arg(long)]
level: String,
},
Show {
rule_id: Uuid,
},
Update {
rule_id: Uuid,
#[arg(long)]
boundary: String,
#[arg(long)]
level: String,
},
Delete {
rule_id: Uuid,
},
Evaluate {
#[arg(long)]
subject: Uuid,
#[arg(long)]
target: Uuid,
},
}
#[derive(Debug, Subcommand)]
pub enum ApprovalCommand {
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
mine: bool,
#[command(flatten)]
page: PageArgs,
},
Show {
request_id: Uuid,
},
Decide {
request_id: Uuid,
#[arg(long)]
decision: String,
#[arg(long)]
reason: Option<String>,
},
RequestRole {
#[arg(long)]
membership_id: Uuid,
#[arg(long)]
job_role: String,
},
RequestTags {
#[arg(long)]
membership_id: Uuid,
#[arg(long = "add", value_name = "TAG_ID")]
add: Vec<Uuid>,
#[arg(long = "remove", value_name = "TAG_ID")]
remove: Vec<Uuid>,
},
SetRole {
membership_id: Uuid,
job_role: String,
},
SetTags {
membership_id: Uuid,
#[arg(long = "tag", value_name = "TAG_ID")]
tags: Vec<Uuid>,
},
RoleHistory {
membership_id: Uuid,
#[command(flatten)]
page: PageArgs,
},
TagHistory {
membership_id: Uuid,
#[command(flatten)]
page: PageArgs,
},
}
#[derive(Debug, Subcommand)]
pub enum SiliconCommand {
List {
#[arg(long)]
tag: Option<Uuid>,
#[command(flatten)]
page: PageArgs,
},
Create {
handle: String,
#[arg(long)]
job_role: String,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
reports_to: Option<Uuid>,
#[arg(long = "tag", value_name = "TAG_ID")]
tags: Vec<Uuid>,
},
Show {
silicon_id: String,
},
Update {
silicon_id: String,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
reports_to: Option<Uuid>,
},
Remove {
silicon_id: String,
#[arg(long)]
reassign_reports_to: Option<Uuid>,
},
RotateRequest {
silicon_id: String,
},
RotateComplete {
silicon_id: String,
request_id: Uuid,
},
Webhook {
silicon_id: String,
},
SetWebhook {
silicon_id: String,
#[arg(long)]
url: String,
},
DeleteWebhook {
silicon_id: String,
},
Subscription {
silicon_id: String,
},
SetSubscription {
silicon_id: String,
#[arg(long, default_value = "all")]
mode: String,
#[arg(long = "topic", value_name = "TOPIC")]
topics: Vec<String>,
#[arg(long = "tag", value_name = "TAG_ID")]
tags: Vec<Uuid>,
},
DeleteSubscription {
silicon_id: String,
},
DeadLetters {
silicon_id: String,
#[command(flatten)]
page: PageArgs,
},
Replay {
silicon_id: String,
#[arg(long = "delivery", value_name = "DELIVERY_ID")]
deliveries: Vec<Uuid>,
},
}
#[derive(Debug, Subcommand)]
pub enum AppCommand {
List {
#[arg(long)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
app_id: String,
#[arg(long)]
name: String,
#[arg(long)]
org: Option<String>,
#[arg(long)]
webhook_url: String,
#[arg(long)]
webhook_secret: String,
#[arg(long)]
base_url: String,
#[arg(long, value_name = "JSON")]
obo_endpoints: Option<String>,
},
Show {
app_id: String,
},
Update {
app_id: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, value_name = "JSON")]
obo_endpoints: Option<String>,
},
RotateSecret {
app_id: String,
},
RotateWebhookSecret {
app_id: String,
#[arg(long)]
webhook_secret: String,
},
Discover {
app_id: String,
#[arg(long = "as-app-id", value_name = "APP_ID")]
requester_app_id: String,
#[arg(long)]
app_secret: Option<String>,
},
#[command(subcommand)]
Token(AppTokenCommand),
#[command(subcommand)]
Obo(AppOboCommand),
VerifyWebhook {
#[arg(value_name = "BODY_FILE")]
body_file: PathBuf,
#[arg(long)]
event_id: String,
#[arg(long)]
timestamp: String,
#[arg(long)]
key_version: String,
#[arg(long)]
signature: String,
#[arg(long)]
webhook_secret: String,
#[arg(long, default_value_t = 300)]
tolerance_seconds: u64,
},
Import {
app_id: String,
},
Webhook {
app_id: String,
},
SetWebhook {
app_id: String,
#[arg(long)]
url: String,
#[arg(long)]
webhook_secret: Option<String>,
},
DeadLetters {
app_id: String,
#[command(flatten)]
page: PageArgs,
},
Replay {
app_id: String,
#[arg(long = "delivery", value_name = "DELIVERY_ID")]
deliveries: Vec<Uuid>,
},
History {
app_id: String,
#[command(flatten)]
page: PageArgs,
},
}
#[derive(Debug, Subcommand)]
pub enum AppTokenCommand {
Exchange {
app_id: String,
#[arg(long)]
slt: Option<String>,
#[arg(long)]
app_secret: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
},
Refresh {
app_id: String,
#[arg(long)]
refresh_token: Option<String>,
#[arg(long)]
app_secret: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
},
Introspect {
app_id: String,
#[arg(long)]
token: Option<String>,
#[arg(long, value_enum)]
token_type: Option<AppTokenType>,
#[arg(long)]
org_context: Option<String>,
#[arg(long)]
app_secret: Option<String>,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum AppTokenType {
AccessToken,
RefreshToken,
}
#[derive(Debug, Subcommand)]
pub enum AppOboCommand {
Endpoints {
audience_app_id: String,
#[arg(long = "as-app-id", value_name = "APP_ID")]
requester_app_id: String,
#[arg(long)]
app_secret: Option<String>,
},
Exchange {
audience_app_id: String,
endpoint_id: String,
#[arg(long = "as-app-id", value_name = "APP_ID")]
requester_app_id: String,
#[arg(long)]
app_secret: Option<String>,
#[arg(long)]
subject_token: Option<String>,
#[arg(long)]
method: String,
#[arg(long, default_value = "{}")]
metadata: String,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long)]
timestamp: Option<i64>,
#[command(flatten)]
body: RequestBodyArgs,
},
Verify {
audience_app_id: String,
#[arg(long)]
app_secret: Option<String>,
#[arg(long)]
access_proof: Option<String>,
#[arg(long)]
method: String,
#[arg(long)]
path: String,
#[command(flatten)]
body: RequestBodyArgs,
},
}
#[derive(Debug, Args)]
pub struct RequestBodyArgs {
#[arg(long, conflicts_with = "body_file")]
pub body: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with = "body")]
pub body_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
pub enum EnvCommand {
List {
#[arg(long)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
name: String,
#[arg(long)]
description: Option<String>,
},
Show {
environment_id: Uuid,
},
Update {
environment_id: Uuid,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
Delete {
environment_id: Uuid,
},
Restore {
environment_id: Uuid,
},
Key {
environment_id: Uuid,
},
RotateKey {
environment_id: Uuid,
},
Clean {
environment_id: Option<Uuid>,
},
Current,
}
#[derive(Debug, Subcommand)]
pub enum SessionCommand {
List(PageArgs),
Revoke {
session_id: Uuid,
},
History(PageArgs),
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Show,
Profiles,
Set {
key: String,
value: String,
},
Unset {
key: String,
},
Use {
profile: String,
},
}
#[derive(Debug, Subcommand)]
pub enum SystemCommand {
Version,
Update,
Health,
}
#[derive(Debug, Args, Default)]
pub struct PageArgs {
#[arg(long)]
pub cursor: Option<String>,
#[arg(long)]
pub limit: Option<u16>,
}
fn parse_testing_environment_id(value: &str) -> Result<Uuid, String> {
let bytes = value.as_bytes();
let hyphenated = bytes.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| bytes.get(index) == Some(&b'-'));
if !hyphenated {
return Err(
"expected a hyphenated testing-environment UUID, never its root key".to_owned(),
);
}
Uuid::parse_str(value).map_err(|_| "expected a valid testing-environment UUID".to_owned())
}
impl PageArgs {
#[must_use]
pub fn paging(&self) -> silicon_iam_client::Paging {
let mut paging = silicon_iam_client::Paging::new();
if let Some(cursor) = &self.cursor {
paging = paging.after(cursor.clone());
}
if let Some(limit) = self.limit {
paging = paging.limit(limit);
}
paging
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory as _;
use super::Cli;
#[test]
fn the_grammar_is_internally_consistent() {
Cli::command().debug_assert();
assert_eq!(Cli::command().get_name(), "iam");
}
#[test]
fn updater_controls_are_reachable() {
use clap::Parser as _;
assert!(Cli::try_parse_from(["iam", "system", "update"]).is_ok());
assert!(Cli::try_parse_from(["iam", "config", "set", "auto-update", "off"]).is_ok());
}
#[test]
fn global_flags_are_accepted_after_the_command_too() {
use clap::Parser as _;
let parsed = Cli::try_parse_from(["iam", "tag", "list", "--org", "acme"]);
assert!(parsed.is_ok(), "{parsed:?}");
let Ok(cli) = parsed else { return };
assert_eq!(cli.global.org.as_deref(), Some("acme"));
}
#[test]
fn login_admits_exactly_one_identity() {
use clap::Parser as _;
assert!(Cli::try_parse_from(["iam", "login", "--email", "a@b.test"]).is_ok());
assert!(
Cli::try_parse_from([
"iam",
"login",
"--email",
"a@b.test",
"--carbon-id",
"someone"
])
.is_err()
);
}
#[test]
fn test_context_accepts_an_environment_id_and_never_a_raw_key() {
use clap::Parser as _;
let id = "0198aa41-52e7-7f32-8ab3-bd42110a6e2c";
let parsed = Cli::try_parse_from(["iam", "--test", id, "whoami"]);
assert!(parsed.is_ok(), "{parsed:?}");
let Ok(cli) = parsed else { return };
assert_eq!(
cli.global.test.map(|value| value.to_string()).as_deref(),
Some(id)
);
assert!(
Cli::try_parse_from(["iam", "--test", &"a".repeat(32), "whoami"]).is_err(),
"a root key must never be accepted where the public test id belongs"
);
}
#[test]
fn application_creation_requires_every_contract_input() {
use clap::Parser as _;
assert!(
Cli::try_parse_from([
"iam",
"app",
"create",
"billing",
"--name",
"Billing",
"--webhook-url",
"https://billing.example/hooks",
"--webhook-secret",
"caller-chosen-webhook-secret-0001",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"iam",
"app",
"create",
"billing",
"--name",
"Billing",
"--webhook-url",
"https://billing.example/hooks",
"--base-url",
"https://billing.example",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"iam",
"app",
"create",
"billing",
"--name",
"Billing",
"--webhook-url",
"https://billing.example/hooks",
"--base-url",
"https://billing.example",
"--webhook-secret",
"caller-chosen-webhook-secret-0001",
])
.is_ok()
);
}
#[test]
fn application_help_surfaces_required_webhook_secrets() {
let command = Cli::command();
let Some(app) = command.find_subcommand("app") else {
panic!("app must exist");
};
for name in ["create", "rotate-webhook-secret", "verify-webhook"] {
let Some(subcommand) = app.find_subcommand(name) else {
panic!("app {name} must exist");
};
let secret = subcommand
.get_arguments()
.find(|argument| argument.get_id() == "webhook_secret");
assert!(
secret.is_some_and(clap::Arg::is_required_set),
"app {name} must require --webhook-secret in its generated usage"
);
}
let Some(create) = app.find_subcommand("create") else {
return;
};
let usage = create.clone().render_usage().to_string();
assert!(usage.contains("--webhook-secret <WEBHOOK_SECRET>"));
}
#[test]
fn every_user_supplied_argument_has_help() {
fn walk(command: &clap::Command, path: &str) {
for argument in command.get_arguments() {
if matches!(argument.get_id().as_str(), "help" | "version") {
continue;
}
assert!(
argument.get_help().is_some(),
"{path} argument {} has no help",
argument.get_id()
);
}
for subcommand in command.get_subcommands() {
let child = format!("{path} {}", subcommand.get_name());
walk(subcommand, &child);
}
}
walk(&Cli::command(), "iam");
}
#[test]
fn application_token_protocol_is_reachable_without_putting_secrets_in_argv() {
use clap::Parser as _;
assert!(Cli::try_parse_from(["iam", "app", "token", "exchange", "acme>checkout"]).is_ok());
assert!(Cli::try_parse_from(["iam", "app", "token", "refresh", "acme>checkout"]).is_ok());
assert!(
Cli::try_parse_from([
"iam",
"app",
"token",
"introspect",
"acme>checkout",
"--token-type",
"access-token",
])
.is_ok()
);
}
#[test]
fn application_obo_protocol_has_all_three_steps_and_lossless_body_input() {
use clap::Parser as _;
assert!(
Cli::try_parse_from([
"iam",
"app",
"obo",
"endpoints",
"acme>billing",
"--as-app-id",
"acme>checkout",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"app",
"obo",
"exchange",
"acme>billing",
"invoices.create",
"--as-app-id",
"acme>checkout",
"--method",
"post",
"--body-file",
"invoice.json",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"app",
"obo",
"verify",
"acme>billing",
"--method",
"POST",
"--path",
"/v1/invoices",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"app",
"obo",
"exchange",
"acme>billing",
"invoices.create",
"--as-app-id",
"acme>checkout",
"--method",
"POST",
"--body",
"{}",
"--body-file",
"invoice.json",
])
.is_err(),
"inline and file bodies are mutually exclusive"
);
}
#[test]
fn offline_webhook_verification_requires_every_signed_header() {
use clap::Parser as _;
let complete = [
"iam",
"app",
"verify-webhook",
"delivery.json",
"--event-id",
"0198aa41-52e7-7f32-8ab3-bd42110a6e2c",
"--timestamp",
"1700000000",
"--key-version",
"1",
"--signature",
"v1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"--webhook-secret",
"caller-chosen-webhook-secret-0001",
];
assert!(Cli::try_parse_from(complete).is_ok());
assert!(
Cli::try_parse_from([
"iam",
"app",
"verify-webhook",
"delivery.json",
"--event-id",
"0198aa41-52e7-7f32-8ab3-bd42110a6e2c",
"--timestamp",
"1700000000",
"--key-version",
"1",
"--webhook-secret",
"caller-chosen-webhook-secret-0001",
])
.is_err()
);
}
}