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)]
pub org: Option<String>,
#[arg(long, global = true, conflicts_with = "org")]
pub no_org: bool,
#[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(LogoutArgs),
Whoami,
StepUp(StepUpArgs),
Signup(SignupArgs),
#[command(subcommand)]
Carbon(CarbonCommand),
Commands,
#[command(subcommand)]
Org(OrgCommand),
#[command(subcommand)]
Sso(SsoCommand),
#[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)]
#[command(group(
clap::ArgGroup::new("identity")
.args(["email", "phone", "carbon_id"])
.required(false)
.multiple(false)
))]
pub struct LoginArgs {
#[arg(long)]
pub email: Option<String>,
#[arg(long)]
pub phone: Option<String>,
#[arg(long)]
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 LogoutArgs {
#[arg(long, conflicts_with = "local_only")]
pub all: bool,
#[arg(long, conflicts_with = "all")]
pub local_only: bool,
}
#[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 CarbonCommand {
Show,
#[command(group(
clap::ArgGroup::new("changes")
.args([
"display_name",
"timezone",
"description",
"clear_description",
"profile_photo",
"clear_profile_photo"
])
.required(true)
.multiple(true)
))]
Update {
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
timezone: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long, conflicts_with = "description")]
clear_description: bool,
#[arg(long)]
profile_photo: Option<String>,
#[arg(long, conflicts_with = "profile_photo")]
clear_profile_photo: bool,
},
Available {
carbon_id: String,
},
Search {
query: String,
#[arg(long, value_parser = clap::value_parser!(u16).range(1..=10))]
limit: Option<u16>,
},
ResolveEmail {
email: String,
},
ResolvePhone {
phone: String,
},
}
#[derive(Debug, Args)]
pub struct StepUpArgs {
#[arg(value_enum)]
pub action: StepUpActionArg,
pub resource_id: Uuid,
#[arg(long, value_enum, default_value_t = StepUpChannel::Email)]
pub channel: StepUpChannel,
#[arg(long)]
pub code: Option<String>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum StepUpActionArg {
#[value(name = "account.session_revoke")]
AccountSessionRevoke,
#[value(name = "account.sessions_revoke_all")]
AccountSessionsRevokeAll,
#[value(name = "organization.transfer_ownership")]
OrganizationTransferOwnership,
#[value(name = "organization.authorization_change")]
OrganizationAuthorizationChange,
#[value(name = "organization.sso_change")]
OrganizationSsoChange,
#[value(name = "organization.silicon_webhook.redirect")]
OrganizationSiliconWebhookRedirect,
#[value(name = "application.client_secret.rotate")]
ApplicationClientSecretRotate,
#[value(name = "application.webhook_secret.rotate")]
ApplicationWebhookSecretRotate,
#[value(name = "silicon.rotate_token")]
SiliconRotateToken,
#[value(name = "platform_admin.sso_entitlement")]
PlatformAdminSsoEntitlement,
#[value(name = "platform_admin.application_review")]
PlatformAdminApplicationReview,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub enum StepUpChannel {
#[default]
Email,
Phone,
}
#[derive(Debug, Subcommand)]
pub enum OrgCommand {
List {
#[arg(long, value_parser = ["active", "removed"])]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
handle: String,
#[arg(long)]
name: String,
#[arg(long)]
description: Option<String>,
},
Show {
handle: Option<String>,
},
#[command(group(
clap::ArgGroup::new("changes")
.args([
"name",
"logo",
"clear_logo",
"description",
"clear_description",
"join_method"
])
.required(true)
.multiple(true)
))]
Update {
handle: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
logo: Option<String>,
#[arg(long, conflicts_with = "logo")]
clear_logo: bool,
#[arg(long)]
description: Option<String>,
#[arg(long, conflicts_with = "description")]
clear_description: bool,
#[arg(long, value_parser = ["email", "sso"])]
join_method: Option<String>,
},
Available {
handle: String,
},
Transfer {
membership_id: Uuid,
#[arg(skip)]
org: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum SsoCommand {
Show,
SetupLink,
Test,
Disable,
}
#[derive(Debug, Subcommand)]
pub enum MemberCommand {
List {
#[arg(long, value_parser = ["carbon", "silicon"])]
principal_type: Option<String>,
#[arg(long, value_name = "TAG_ID", value_parser = parse_tag_id)]
tag: Option<Uuid>,
#[arg(long, value_parser = ["active", "removed"])]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Show {
membership_id: Uuid,
},
Authorization {
membership_id: Uuid,
},
#[command(group(
clap::ArgGroup::new("changes")
.args([
"first_silicon",
"clear_first_silicon",
"reports_to",
"clear_reports_to",
"profile_photo",
"clear_profile_photo"
])
.required(true)
.multiple(true)
))]
Update {
membership_id: Uuid,
#[arg(long)]
first_silicon: Option<Uuid>,
#[arg(long, conflicts_with = "first_silicon")]
clear_first_silicon: bool,
#[arg(long)]
reports_to: Option<Uuid>,
#[arg(long, conflicts_with = "reports_to")]
clear_reports_to: bool,
#[arg(long)]
profile_photo: Option<String>,
#[arg(long, conflicts_with = "profile_photo")]
clear_profile_photo: bool,
},
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",
value_parser = [
"organization.update",
"members.invite",
"members.update_directory",
"members.remove",
"silicons.create",
"silicons.update_directory",
"silicons.manage_hierarchy",
"silicons.remove",
"silicons.rotate_token",
"tags.manage",
"trust.manage",
"roles.request",
"roles.approve",
"admins.create",
"admins.manage",
"sso.manage"
]
)]
capabilities: Vec<String>,
},
Directory {
#[arg(long, value_parser = parse_directory_fields)]
fields: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Self_ {
#[arg(long, value_parser = parse_directory_fields)]
fields: Option<String>,
},
DirectoryMember {
membership_id: Uuid,
#[arg(long, value_parser = parse_directory_fields)]
fields: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum InviteCommand {
List {
#[arg(
long,
value_parser = ["pending", "accepted", "revoked", "expired"]
)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
#[command(group(
clap::ArgGroup::new("identity")
.args(["carbon_id", "email"])
.required(true)
.multiple(false)
))]
Create {
#[arg(long)]
carbon_id: Option<String>,
#[arg(long)]
email: Option<String>,
#[arg(long)]
job_role: String,
#[arg(
long,
default_value = "internal",
value_parser = ["internal", "external"]
)]
boundary: String,
#[arg(
long,
default_value = "not_trusted",
value_parser = ["not_trusted", "needs_approval", "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, value_parser = ["internal", "external"])]
boundary: String,
#[arg(
long,
value_parser = ["not_trusted", "needs_approval", "trusted"]
)]
level: String,
},
List(PageArgs),
#[command(
group(
clap::ArgGroup::new("subject")
.args(["subject_tag", "subject_membership"])
.required(true)
.multiple(false)
),
group(
clap::ArgGroup::new("target")
.args(["target_tag", "target_membership"])
.required(true)
.multiple(false)
)
)]
Create {
#[arg(long, value_name = "TAG_ID")]
subject_tag: Option<Uuid>,
#[arg(long, value_name = "MEMBERSHIP_ID")]
subject_membership: Option<Uuid>,
#[arg(long, value_name = "TAG_ID")]
target_tag: Option<Uuid>,
#[arg(long, value_name = "SILICON_MEMBERSHIP_ID")]
target_membership: Option<Uuid>,
#[arg(long, value_parser = ["internal", "external"])]
boundary: String,
#[arg(
long,
value_parser = ["not_trusted", "needs_approval", "trusted"]
)]
level: String,
},
Show {
rule_id: Uuid,
},
Update {
rule_id: Uuid,
#[arg(long, value_parser = ["internal", "external"])]
boundary: String,
#[arg(
long,
value_parser = ["not_trusted", "needs_approval", "trusted"]
)]
level: String,
},
Delete {
rule_id: Uuid,
},
Evaluate {
#[arg(long, value_name = "MEMBERSHIP_ID")]
subject: Uuid,
#[arg(long, value_name = "SILICON_MEMBERSHIP_ID")]
target: Uuid,
},
}
#[derive(Debug, Subcommand)]
pub enum ApprovalCommand {
List {
#[arg(
long,
value_parser = ["pending", "approved", "rejected", "completed"]
)]
status: Option<String>,
#[arg(
long,
value_parser = [
"carbon_job_role_change",
"silicon_job_role_change",
"carbon_tag_change",
"silicon_tag_change",
"silicon_token_rotation"
]
)]
kind: Option<String>,
#[arg(long)]
mine: bool,
#[command(flatten)]
page: PageArgs,
},
Show {
request_id: Uuid,
},
Decide {
request_id: Uuid,
#[arg(long, value_parser = ["approve", "reject"])]
decision: String,
#[arg(long)]
reason: Option<String>,
},
RequestRole {
#[arg(long)]
membership_id: Uuid,
#[arg(long)]
job_role: String,
},
#[command(group(
clap::ArgGroup::new("changes")
.args(["add", "remove"])
.required(true)
.multiple(true)
))]
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, value_name = "TAG_ID", value_parser = parse_tag_id)]
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,
},
#[command(group(
clap::ArgGroup::new("changes")
.args([
"display_name",
"timezone",
"description",
"clear_description",
"profile_photo",
"clear_profile_photo",
"reports_to",
"clear_reports_to"
])
.required(true)
.multiple(true)
))]
Update {
silicon_id: String,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
timezone: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long, conflicts_with = "description")]
clear_description: bool,
#[arg(long)]
profile_photo: Option<String>,
#[arg(long, conflicts_with = "profile_photo")]
clear_profile_photo: bool,
#[arg(long)]
reports_to: Option<Uuid>,
#[arg(long, conflicts_with = "reports_to")]
clear_reports_to: bool,
},
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 = "webhook-url")]
webhook_url: String,
},
DeleteWebhook {
silicon_id: String,
},
Subscription {
silicon_id: String,
},
SetSubscription {
silicon_id: String,
#[arg(long, default_value = "all", value_parser = ["all", "selected"])]
mode: String,
#[arg(
long = "topic",
value_name = "TOPIC",
value_parser = [
"membership_lifecycle",
"member_updates",
"trust_updates"
],
required_if_eq("mode", "selected")
)]
topics: Vec<String>,
#[arg(long = "tag", value_name = "TAG_ID")]
tags: Vec<Uuid>,
#[arg(long, conflicts_with = "tags")]
own_tags_only: bool,
},
DeleteSubscription {
silicon_id: String,
},
DeadLetters {
silicon_id: String,
#[command(flatten)]
page: PageArgs,
},
Replay {
silicon_id: String,
#[arg(long = "delivery", value_name = "DELIVERY_ID", required = true)]
deliveries: Vec<Uuid>,
},
}
#[derive(Debug, Subcommand)]
pub enum AppCommand {
List {
#[arg(
long,
value_parser = [
"under_review",
"verified",
"rejected",
"suspended",
"deleted"
]
)]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
app_id: String,
#[arg(long)]
name: String,
#[arg(skip)]
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,
},
#[command(group(
clap::ArgGroup::new("changes")
.args([
"name",
"clear_name",
"logo",
"clear_logo",
"base_url",
"obo_endpoints"
])
.required(true)
.multiple(true)
))]
Update {
app_id: String,
#[arg(long)]
name: Option<String>,
#[arg(long, conflicts_with = "name")]
clear_name: bool,
#[arg(long)]
logo: Option<String>,
#[arg(long, conflicts_with = "logo")]
clear_logo: bool,
#[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 = "webhook-url")]
webhook_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", required = true)]
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>,
},
Revoke {
app_id: String,
#[arg(long)]
token: Option<String>,
#[arg(long, value_enum)]
token_type: Option<AppTokenType>,
#[arg(long)]
app_secret: Option<String>,
#[arg(long)]
idempotency_key: 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, value_parser = ["active", "deleted", "all"])]
status: Option<String>,
#[command(flatten)]
page: PageArgs,
},
Create {
name: String,
#[arg(long)]
description: Option<String>,
},
Show {
environment_id: Uuid,
},
#[command(group(
clap::ArgGroup::new("changes")
.args(["name", "description", "clear_description"])
.required(true)
.multiple(true)
))]
Update {
environment_id: Uuid,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long, conflicts_with = "description")]
clear_description: bool,
},
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 {
#[arg(value_parser = ["url", "org", "auto-update"])]
key: String,
value: String,
},
Unset {
#[arg(value_parser = ["org", "auto-update"])]
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, value_parser = clap::value_parser!(u16).range(1..=100))]
pub limit: Option<u16>,
}
fn parse_directory_fields(value: &str) -> Result<String, String> {
const ALLOWED: [&str; 6] = ["name", "id", "role", "org", "tags", "trust"];
let fields = value.split(',').map(str::trim).collect::<Vec<_>>();
if fields.iter().any(|field| field.is_empty()) {
return Err(format!(
"expected a comma-separated field list: {}",
ALLOWED.join(",")
));
}
if let Some(invalid) = fields.iter().find(|field| !ALLOWED.contains(field)) {
return Err(format!(
"unknown directory field `{invalid}`; expected one of {}",
ALLOWED.join(",")
));
}
Ok(fields.join(","))
}
fn parse_tag_id(value: &str) -> Result<Uuid, String> {
Uuid::parse_str(value).map_err(|_| "expected a tag UUID from `iam tag list`".to_owned())
}
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 organization_scope_has_one_unambiguous_global_flag() {
use clap::Parser as _;
let parsed = Cli::try_parse_from([
"iam",
"app",
"create",
"billing",
"--name",
"Billing",
"--webhook-url",
"https://billing.example/hooks",
"--webhook-secret",
"caller-chosen-webhook-secret-0001",
"--base-url",
"https://billing.example",
"--org",
"acme",
]);
assert!(parsed.is_ok(), "the global --org must work: {parsed:?}");
let Ok(cli) = parsed else { return };
assert_eq!(cli.global.org.as_deref(), Some("acme"));
assert!(matches!(
cli.command,
super::Command::App(super::AppCommand::Create { org: None, .. })
));
let member_id = "0198aa41-52e7-7f32-8ab3-bd42110a6e2c";
let parsed = Cli::try_parse_from(["iam", "org", "transfer", member_id, "--org", "acme"]);
assert!(parsed.is_ok(), "the global --org must work: {parsed:?}");
let Ok(cli) = parsed else { return };
assert_eq!(cli.global.org.as_deref(), Some("acme"));
assert!(matches!(
cli.command,
super::Command::Org(super::OrgCommand::Transfer { org: None, .. })
));
}
#[test]
fn webhook_urls_cannot_overwrite_the_service_url() {
use clap::Parser as _;
let parsed = Cli::try_parse_from([
"iam",
"--url",
"http://127.0.0.1:8080",
"silicon",
"set-webhook",
"builder",
"--webhook-url",
"https://hooks.example/events",
]);
assert!(parsed.is_ok(), "{parsed:?}");
let Ok(cli) = parsed else { return };
assert_eq!(cli.global.url.as_deref(), Some("http://127.0.0.1:8080"));
assert!(matches!(
cli.command,
super::Command::Silicon(super::SiliconCommand::SetWebhook {
webhook_url,
..
}) if webhook_url == "https://hooks.example/events"
));
let parsed = Cli::try_parse_from([
"iam",
"app",
"set-webhook",
"acme>console",
"--webhook-url",
"https://hooks.example/app",
]);
assert!(matches!(
parsed,
Ok(Cli {
command: super::Command::App(super::AppCommand::SetWebhook {
webhook_url,
..
}),
..
}) if webhook_url == "https://hooks.example/app"
));
}
#[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"]).is_err());
assert!(
Cli::try_parse_from([
"iam",
"login",
"--email",
"a@b.test",
"--carbon-id",
"someone"
])
.is_err()
);
}
#[test]
fn page_limits_are_bounded_by_the_service_contract() {
use clap::Parser as _;
assert!(Cli::try_parse_from(["iam", "org", "list", "--limit", "1"]).is_ok());
assert!(Cli::try_parse_from(["iam", "org", "list", "--limit", "100"]).is_ok());
assert!(Cli::try_parse_from(["iam", "org", "list", "--limit", "0"]).is_err());
assert!(Cli::try_parse_from(["iam", "org", "list", "--limit", "101"]).is_err());
}
#[test]
fn closed_contract_values_are_rejected_before_network_io() {
use clap::Parser as _;
let invalid = [
vec!["iam", "org", "update", "--join-method", "password"],
vec!["iam", "member", "list", "--principal-type", "robot"],
vec!["iam", "member", "list", "--status", "pending"],
vec!["iam", "invite", "list", "--status", "unknown"],
vec![
"iam",
"trust",
"set-default",
"--boundary",
"partner",
"--level",
"trusted",
],
vec!["iam", "approval", "list", "--status", "cancelled"],
vec!["iam", "approval", "list", "--kind", "role_change"],
vec![
"iam",
"approval",
"decide",
"0198aa41-52e7-7f32-8ab3-bd42110a6e2c",
"--decision",
"abstain",
],
vec!["iam", "app", "list", "--status", "active"],
vec!["iam", "env", "list", "--status", "retired"],
vec!["iam", "config", "set", "unknown", "value"],
vec!["iam", "config", "unset", "url"],
vec!["iam", "member", "directory", "--fields", "name,password"],
];
for argv in invalid {
assert!(
Cli::try_parse_from(argv.clone()).is_err(),
"unexpectedly accepted {argv:?}"
);
}
assert!(Cli::try_parse_from(["iam", "org", "update", "--join-method", "sso"]).is_ok());
assert!(
Cli::try_parse_from(["iam", "member", "list", "--principal-type", "silicon"]).is_ok()
);
assert!(
Cli::try_parse_from(["iam", "member", "directory", "--fields", "name, tags"]).is_ok()
);
assert!(Cli::try_parse_from(["iam", "app", "list", "--status", "verified"]).is_ok());
}
#[test]
fn silicon_subscriptions_use_closed_modes_and_topics() {
use clap::Parser as _;
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"set-subscription",
"builder",
"--mode",
"all",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"set-subscription",
"builder",
"--mode",
"selected",
"--topic",
"member_updates",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"set-subscription",
"builder",
"--mode",
"selected",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"set-subscription",
"builder",
"--mode",
"some",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"set-subscription",
"builder",
"--topic",
"everything",
])
.is_err()
);
}
#[test]
fn mutating_commands_reject_missing_payloads() {
use clap::Parser as _;
let id = "0198aa41-52e7-7f32-8ab3-bd42110a6e2c";
let missing = [
vec!["iam", "org", "update"],
vec!["iam", "member", "update", id],
vec!["iam", "invite", "create", "--job-role", "Engineer"],
vec![
"iam",
"trust",
"create",
"--boundary",
"internal",
"--level",
"trusted",
],
vec!["iam", "approval", "request-tags", "--membership-id", id],
vec!["iam", "silicon", "update", "builder"],
vec!["iam", "app", "update", "billing"],
vec!["iam", "env", "update", id],
vec!["iam", "silicon", "replay", "builder"],
vec!["iam", "app", "replay", "billing"],
];
for argv in missing {
assert!(
Cli::try_parse_from(argv.clone()).is_err(),
"unexpectedly accepted no-op mutation {argv:?}"
);
}
}
#[test]
fn mutating_commands_accept_a_real_payload() {
use clap::Parser as _;
let id = "0198aa41-52e7-7f32-8ab3-bd42110a6e2c";
assert!(Cli::try_parse_from(["iam", "org", "update", "--name", "Acme"]).is_ok());
assert!(
Cli::try_parse_from([
"iam",
"member",
"update",
id,
"--profile-photo",
"https://example.test/a.png"
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"invite",
"create",
"--email",
"person@example.test",
"--job-role",
"Engineer",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"trust",
"create",
"--subject-membership",
id,
"--target-membership",
id,
"--boundary",
"internal",
"--level",
"trusted",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"approval",
"request-tags",
"--membership-id",
id,
"--add",
id,
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"iam",
"silicon",
"update",
"builder",
"--display-name",
"Builder"
])
.is_ok()
);
assert!(
Cli::try_parse_from(["iam", "app", "update", "billing", "--name", "Billing"]).is_ok()
);
assert!(
Cli::try_parse_from(["iam", "env", "update", id, "--description", "proof"]).is_ok()
);
assert!(
Cli::try_parse_from(["iam", "silicon", "replay", "builder", "--delivery", id]).is_ok()
);
assert!(Cli::try_parse_from(["iam", "app", "replay", "billing", "--delivery", id]).is_ok());
}
#[test]
fn privileged_help_names_the_step_up_contract() {
fn command_at<'a>(root: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
path.iter().fold(root, |command, name| {
command
.find_subcommand(name)
.unwrap_or_else(|| panic!("missing command {}", path.join(" ")))
})
}
let command = Cli::command();
let contracts = [
(&["org", "transfer"][..], "organization.transfer_ownership"),
(
&["member", "remove"][..],
"organization.authorization_change",
),
(
&["member", "promote"][..],
"organization.authorization_change",
),
(
&["member", "demote"][..],
"organization.authorization_change",
),
(
&["member", "capabilities"][..],
"organization.authorization_change",
),
(
&["silicon", "remove"][..],
"organization.authorization_change",
),
(&["silicon", "rotate-request"][..], "silicon.rotate_token"),
(&["silicon", "rotate-complete"][..], "silicon.rotate_token"),
(
&["silicon", "set-webhook"][..],
"organization.silicon_webhook.redirect",
),
(
&["silicon", "delete-webhook"][..],
"organization.silicon_webhook.redirect",
),
(
&["silicon", "set-subscription"][..],
"organization.silicon_webhook.redirect",
),
(
&["silicon", "delete-subscription"][..],
"organization.silicon_webhook.redirect",
),
(
&["app", "rotate-secret"][..],
"application.client_secret.rotate",
),
(
&["app", "rotate-webhook-secret"][..],
"application.webhook_secret.rotate",
),
(&["session", "revoke"][..], "account.session_revoke"),
(&["approval", "decide"][..], "silicon.rotate_token"),
];
for (path, action) in contracts {
let subcommand = command_at(&command, path);
let about = subcommand
.get_long_about()
.or_else(|| subcommand.get_about())
.map(ToString::to_string)
.unwrap_or_default();
assert!(
about.to_ascii_lowercase().contains("needs --step-up"),
"{} help omits the step-up requirement: {about}",
path.join(" ")
);
assert!(
about.contains(action) && about.contains("resource"),
"{} help omits {action} or its resource: {about}",
path.join(" ")
);
}
}
#[test]
fn silicon_only_approval_requests_say_so_in_help() {
let command = Cli::command();
let Some(approval) = command.find_subcommand("approval") else {
panic!("approval exists")
};
for name in ["request-role", "request-tags"] {
let Some(subcommand) = approval.find_subcommand(name) else {
panic!("approval {name} exists")
};
let about = subcommand
.get_long_about()
.or_else(|| subcommand.get_about())
.map(ToString::to_string)
.unwrap_or_default();
assert!(
about.contains("Silicon-only") && about.contains("Carbon callers are forbidden"),
"approval {name} help must state its caller boundary: {about}"
);
}
}
#[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",
"revoke",
"acme>checkout",
"--token-type",
"refresh-token",
])
.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()
);
}
}