diff --git a/Cargo.toml b/Cargo.toml index 929acb3..cc04a2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,11 @@ thiserror = "1" indicatif = "0.17" chrono = { version = "0.4", features = ["serde"] } libc = "0.2" +async-trait = "0.1" +aws-config = { version = "1", features = ["behavior-version-latest"] } +aws-sdk-sts = "1" +aws-sdk-iam = "1" +aws-sdk-cloudformation = "1" [dev-dependencies] mockito = "1" diff --git a/README.md b/README.md index 703432f..0d70f00 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ plerion --profile prod findings list | `alerts list` | Risk-based alerts (supports `--all`) | | `audit-logs list` | Audit logs (supports `--all`) | | `integrations list` | Cloud integrations (supports `--all`) | +| `integrations add aws` | Onboard an AWS account end-to-end (preflight, CloudFormation deploy, registration) | | `risks list` | Security risks (supports `--all`) | | `vulnerabilities list` | Vulnerabilities (supports `--all`) | | `vulnerabilities exemptions list/get/create/update/delete` | Vulnerability exemptions (list supports `--all`) | @@ -183,6 +184,32 @@ plerion --profile prod findings list Most list commands accept `--per-page ` (default 50) and `--sort-by`/`--sort-order`. Run `plerion --help` for all options. +### Onboarding an AWS account + +```bash +# Preview everything the command would do (no network calls) +plerion integrations add aws --dry-run + +# Run all checks without deploying +plerion integrations add aws --aws-profile prod --aws-region ap-southeast-2 --validate-only + +# Onboard (interactive confirmation before anything is created) +plerion integrations add aws --aws-profile prod --aws-region ap-southeast-2 + +# Non-interactive (CI): pin the expected account and skip the prompt +plerion integrations add aws --yes --expect-account-id 123456789012 +``` + +AWS credentials come from the standard AWS credential chain (`--aws-profile` +selects a profile; SSO/Identity Center works out of the box). The command runs +preflight checks (account identity, existing stacks/integrations, an advisory +IAM permission simulation, template validation), asks for one confirmation, +deploys Plerion's CloudFormation stack, and the stack registers the +integration automatically. Exit codes: `0` success, `1` config/API error, +`2` preflight failure, `3` deploy failure, `4` account already onboarded +(override with `--allow-existing`). If the Plerion-managed scanning service +account cannot be resolved automatically, pass `--service-account-id `. + > **Note:** The global `--region` flag selects the API endpoint region. Some commands like `findings list` also have a `--region` flag that filters by cloud resource region (e.g. `us-east-1`). These are independent. ## Output formats diff --git a/src/api/endpoints/aws.rs b/src/api/endpoints/aws.rs index 6750fb0..3f1ae59 100644 --- a/src/api/endpoints/aws.rs +++ b/src/api/endpoints/aws.rs @@ -14,15 +14,20 @@ pub async fn get_cloudformation_template( client.execute(req).await } +/// Generate a temporary integration token. +/// +/// With `Some(integration_id)` the token is scoped to an existing integration +/// (the `plerion aws generate-token` behavior). With `None` the request is a +/// bare POST, which mints the onboarding token used to register a NEW AWS +/// account integration via CloudFormation. pub async fn generate_token( client: &PlerionClient, - integration_id: &str, + integration_id: Option<&str>, ) -> Result { - client - .execute( - client - .post("/v1/tenant/integrations/token") - .json(&serde_json::json!({ "integrationId": integration_id })), - ) - .await + let req = client.post("/v1/tenant/integrations/token"); + let req = match integration_id { + Some(id) => req.json(&serde_json::json!({ "integrationId": id })), + None => req, + }; + client.execute(req).await } diff --git a/src/api/models/aws.rs b/src/api/models/aws.rs new file mode 100644 index 0000000..de68362 --- /dev/null +++ b/src/api/models/aws.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +/// Typed envelopes for the AWS integration endpoints, used by the onboarding +/// orchestrator. The raw `plerion aws ...` subcommands keep returning +/// `serde_json::Value` so their passthrough output is unchanged. + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalIdResponse { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalIdData { + pub external_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfnTemplateResponse { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CfnTemplateData { + #[serde(rename = "templateURL")] + pub template_url: Option, + pub template_version: Option, + /// Not returned by the API today; picked up automatically if/when the + /// tenant's Plerion-managed CWPP service account ID is exposed here. + pub service_account_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenResponse { + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenData { + pub token: Option, +} diff --git a/src/api/models/mod.rs b/src/api/models/mod.rs index 8d49fdb..172a571 100644 --- a/src/api/models/mod.rs +++ b/src/api/models/mod.rs @@ -2,6 +2,7 @@ pub mod alerts; pub mod asset_groups; pub mod assets; pub mod audit_logs; +pub mod aws; pub mod compliance; pub mod findings; pub mod iac; diff --git a/src/cli/aws.rs b/src/cli/aws.rs index b661793..9a17dd3 100644 --- a/src/cli/aws.rs +++ b/src/cli/aws.rs @@ -35,7 +35,7 @@ pub async fn run(args: &AwsArgs, config: &Config) -> anyhow::Result<()> { output::render_json_value(&resp, config.output, config.query.as_deref())?; } AwsCommands::GenerateToken { integration_id } => { - let resp = generate_token(&client, integration_id).await?; + let resp = generate_token(&client, Some(integration_id)).await?; output::render_json_value(&resp, config.output, config.query.as_deref())?; } } diff --git a/src/cli/integrations.rs b/src/cli/integrations.rs index 511f29e..317bd60 100644 --- a/src/cli/integrations.rs +++ b/src/cli/integrations.rs @@ -1,6 +1,7 @@ use clap::{Args, Subcommand}; use crate::api::{client::PlerionClient, endpoints::integrations::list_integrations}; use crate::config::Config; +use crate::onboard; use crate::output; #[derive(Args, Debug)] @@ -12,6 +13,8 @@ pub struct IntegrationsArgs { #[derive(Subcommand, Debug)] pub enum IntegrationsCommands { List(ListIntegrationsArgs), + /// Onboard a cloud account as a new integration + Add(AddArgs), } #[derive(Args, Debug)] @@ -22,10 +25,83 @@ pub struct ListIntegrationsArgs { #[arg(long)] pub all: bool, } +#[derive(Args, Debug)] +pub struct AddArgs { + #[command(subcommand)] + pub provider: AddProvider, +} + +#[derive(Subcommand, Debug)] +pub enum AddProvider { + /// Onboard an AWS account: preflight checks, CloudFormation deploy, + /// automatic registration with Plerion + Aws(AddAwsArgs), +} + +#[derive(Args, Debug)] +pub struct AddAwsArgs { + /// AWS CLI profile for the target account (default: AWS default credential chain) + #[arg(long)] + pub aws_profile: Option, + + /// AWS region to deploy the stack in (IAM is global; any enabled region works) + #[arg(long)] + pub aws_region: Option, + + /// Abort unless the resolved AWS account matches (recommended with --yes) + #[arg(long)] + pub expect_account_id: Option, + + /// Skip interactive confirmation + #[arg(long, short = 'y')] + pub yes: bool, + + /// CloudFormation stack name (must start with "Plerion-" unless --no-auto-update) + #[arg(long, default_value = onboard::DEFAULT_STACK_NAME)] + pub stack_name: String, + + /// Do not create the Plerion auto-update role + #[arg(long)] + pub no_auto_update: bool, + + /// KMS key access mode for workload scanning + #[arg(long, default_value = "ALL_KEYS", value_parser = ["ALL_KEYS", "SELECTED_KEYS"])] + pub kms_key_access_mode: String, + + /// Plerion-managed CWPP service account ID (only needed when it cannot be + /// resolved automatically) + #[arg(long)] + pub service_account_id: Option, + + /// Proceed even if the account already has a Plerion integration or roles + #[arg(long)] + pub allow_existing: bool, + + /// Treat IAM simulation denials as fatal (default: advisory — the + /// simulator has known false negatives with AWS Identity Center roles) + #[arg(long)] + pub strict_preflight: bool, + + /// Run every preflight check, deploy nothing + #[arg(long)] + pub validate_only: bool, + + /// Print the execution plan without any network calls + #[arg(long)] + pub dry_run: bool, + + /// Seconds to wait for stack completion + #[arg(long, default_value = "1800")] + pub wait_timeout: u64, + + #[arg(long, default_value = onboard::PLERION_ACCOUNT_ID_DEFAULT, hide = true)] + pub plerion_account_id: String, +} + pub async fn run(args: &IntegrationsArgs, config: &Config) -> anyhow::Result<()> { - let client = PlerionClient::new(config)?; match &args.command { IntegrationsCommands::List(a) => { + let client = PlerionClient::new(config)?; if a.all { let mut all_items = Vec::new(); let mut cursor: Option = None; @@ -41,6 +117,60 @@ pub async fn run(args: &IntegrationsArgs, config: &Config) -> anyhow::Result<()> let resp = list_integrations(&client, Some(a.per_page), None, a.include_total).await?; output::render_list(&resp.data, config.output, config.query.as_deref(), config.no_color)?; } + Ok(()) + } + IntegrationsCommands::Add(add) => match &add.provider { + AddProvider::Aws(a) => run_add_aws(a, config).await, + }, + } +} + +/// The host the deployed stack reports back to. Derived from the tenant +/// region, or from --endpoint-url when overridden (dev tenants). +fn plerion_url(config: &Config) -> String { + match &config.endpoint_url { + Some(url) => url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/') + .to_string(), + None => format!("{}.api.plerion.com", config.region), + } +} + +async fn run_add_aws(args: &AddAwsArgs, config: &Config) -> anyhow::Result<()> { + let opts = onboard::OnboardOptions { + stack_name: args.stack_name.clone(), + auto_update: !args.no_auto_update, + kms_key_access_mode: args.kms_key_access_mode.clone(), + service_account_id: args.service_account_id.clone(), + expect_account_id: args.expect_account_id.clone(), + allow_existing: args.allow_existing, + strict_preflight: args.strict_preflight, + validate_only: args.validate_only, + yes: args.yes, + wait_timeout_secs: args.wait_timeout, + poll_interval_secs: 15, + plerion_account_id: args.plerion_account_id.clone(), + plerion_url: plerion_url(config), + tenant_label: config + .endpoint_url + .clone() + .unwrap_or_else(|| config.region.clone()), + }; + + if args.dry_run { + print!("{}", onboard::plan::render(&opts)); + return Ok(()); + } + + let client = PlerionClient::new(config)?; + let aws = onboard::sdk::SdkAws::new(args.aws_profile.as_deref(), args.aws_region.as_deref()).await; + let mut ui = onboard::ui::TtyUi; + + match onboard::run(&client, &aws, &mut ui, &opts).await? { + onboard::OnboardOutcome::Completed(result) | onboard::OnboardOutcome::Validated(result) => { + output::render_json_value(&result, config.output, config.query.as_deref())?; } } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 76249a8..fb2b9d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod api; pub mod cli; pub mod config; pub mod error; +pub mod onboard; pub mod output; diff --git a/src/main.rs b/src/main.rs index 3603a8e..e879095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod cli; mod config; mod error; +mod onboard; mod output; use clap::Parser; @@ -68,6 +69,12 @@ async fn main() { if let Err(e) = result { eprintln!("Error: {e}"); - std::process::exit(1); + // `integrations add aws` has a documented exit-code contract + // (2 preflight, 3 deploy, 4 already onboarded); everything else exits 1. + let code = e + .downcast_ref::() + .map(|oe| oe.exit_code()) + .unwrap_or(1); + std::process::exit(code); } } diff --git a/src/onboard/aws_api.rs b/src/onboard/aws_api.rs new file mode 100644 index 0000000..1aa1da0 --- /dev/null +++ b/src/onboard/aws_api.rs @@ -0,0 +1,71 @@ +use crate::onboard::OnboardError; +use serde::Serialize; + +/// Owned domain types + a trait over the AWS calls the onboarding flow makes. +/// `sdk.rs` is the only production implementation; tests supply a scripted +/// mock so the orchestration state machine is unit-testable without AWS. + +#[derive(Debug, Clone)] +pub struct CallerIdentity { + pub account: String, + pub arn: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SimResult { + pub action: String, + pub allowed: bool, +} + +#[derive(Debug, Clone)] +pub struct TemplateSummary { + pub description: Option, +} + +#[derive(Debug, Clone)] +pub struct CreateStackRequest { + pub stack_name: String, + pub template_url: String, + /// (ParameterKey, ParameterValue) pairs. AuthToken is NoEcho in the + /// template and must never be rendered by callers. + pub parameters: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StackEvent { + pub logical_id: String, + pub status: String, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StackOutput { + pub key: String, + pub value: String, +} + +#[async_trait::async_trait] +pub trait AwsApi: Send + Sync { + async fn caller_identity(&self) -> Result; + /// Best-effort; implementations return Ok(None) when the alias cannot be read. + async fn account_alias(&self) -> Result, OnboardError>; + /// Returns the stack status string, or None when no such stack exists. + async fn find_stack(&self, name: &str) -> Result, OnboardError>; + /// Role names containing `needle` (paginated ListRoles). + async fn list_roles_matching(&self, needle: &str) -> Result, OnboardError>; + /// Resolve a role name to its full ARN (handles path'd roles, e.g. Identity Center). + async fn role_arn(&self, role_name: &str) -> Result; + async fn simulate( + &self, + policy_source_arn: &str, + actions: &[String], + ) -> Result, OnboardError>; + async fn template_summary(&self, template_url: &str) -> Result; + /// Returns the StackId. + async fn create_stack(&self, req: &CreateStackRequest) -> Result; + async fn stack_failure_events(&self, name: &str) -> Result, OnboardError>; + async fn stack_outputs(&self, name: &str) -> Result, OnboardError>; +} diff --git a/src/onboard/deploy.rs b/src/onboard/deploy.rs new file mode 100644 index 0000000..e466f89 --- /dev/null +++ b/src/onboard/deploy.rs @@ -0,0 +1,82 @@ +use crate::onboard::aws_api::AwsApi; +use crate::onboard::{OnboardError, OnboardOptions}; +use std::time::Duration; + +/// Poll the stack until CREATE_COMPLETE, a terminal failure, or timeout. +/// Returns the final status string on success. +pub async fn wait_for_stack( + aws: &dyn AwsApi, + stack_id: &str, + opts: &OnboardOptions, +) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(opts.wait_timeout_secs); + let mut last_status = String::new(); + loop { + // Transient DescribeStacks failures (throttling, credential refresh) + // shouldn't kill a deploy that's still running — keep polling. + let status = match aws.find_stack(stack_id).await { + Ok(Some(s)) => s, + Ok(None) => { + return Err(OnboardError::DeployFailed( + "stack disappeared while waiting".to_string(), + )) + } + Err(_) => { + if tokio::time::Instant::now() >= deadline { + return Err(OnboardError::DeployFailed( + "timed out polling stack status".to_string(), + )); + } + tokio::time::sleep(Duration::from_secs(opts.poll_interval_secs)).await; + continue; + } + }; + + if status != last_status { + eprintln!(" {status}"); + last_status = status.clone(); + } + + match status.as_str() { + "CREATE_COMPLETE" => return Ok(status), + "CREATE_IN_PROGRESS" => {} + _ => { + // Terminal failure: surface the actual resource failures. + let events = aws.stack_failure_events(stack_id).await.unwrap_or_default(); + let mut lines = Vec::new(); + let mut token_hint = false; + for e in &events { + if e.logical_id == "PlerionAPICall" { + token_hint = true; + } + lines.push(format!( + " {}: {} ({})", + e.logical_id, + e.status, + e.reason.as_deref().unwrap_or("no reason") + )); + } + let mut msg = format!("stack entered {status}"); + if !lines.is_empty() { + msg = format!("{msg}\n{}", lines.join("\n")); + } + if token_hint { + msg = format!( + "{msg}\n hint: a PlerionAPICall failure usually means the \ + registration token expired or was rejected — delete the rolled-back \ + stack and re-run (a fresh token is generated every run)" + ); + } + return Err(OnboardError::DeployFailed(msg)); + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(OnboardError::DeployFailed(format!( + "timed out after {}s waiting for stack completion (status: {status})", + opts.wait_timeout_secs + ))); + } + tokio::time::sleep(Duration::from_secs(opts.poll_interval_secs)).await; + } +} diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs new file mode 100644 index 0000000..5c97b08 --- /dev/null +++ b/src/onboard/mod.rs @@ -0,0 +1,328 @@ +pub mod aws_api; +pub mod deploy; +pub mod plan; +pub mod preflight; +pub mod sdk; +pub mod ui; + +use crate::api::client::PlerionClient; +use crate::api::endpoints::aws as aws_endpoints; +use crate::api::models::aws::{CfnTemplateResponse, ExternalIdResponse, TokenResponse}; +use crate::error::PlerionError; +use aws_api::{AwsApi, CreateStackRequest}; +use thiserror::Error; +use ui::Ui; + +pub const PLERION_ACCOUNT_ID_DEFAULT: &str = "588158338731"; +pub const DEFAULT_STACK_NAME: &str = "Plerion-Integration"; + +/// Actions simulated in preflight — mirrors the verified minimum deployer +/// policy's deploy statement. +pub const REQUIRED_ACTIONS: &[&str] = &[ + "cloudformation:GetTemplateSummary", + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "iam:CreateRole", + "iam:GetRole", + "iam:TagRole", + "iam:PutRolePolicy", + "iam:AttachRolePolicy", + "iam:PassRole", + "iam:CreatePolicy", + "iam:GetPolicy", + "iam:GetRolePolicy", + "iam:ListPolicyVersions", + "iam:ListRoles", + "lambda:CreateFunction", + "lambda:GetFunction", + "lambda:InvokeFunction", + "lambda:TagResource", +]; + +#[derive(Debug, Clone)] +pub struct OnboardOptions { + pub stack_name: String, + pub auto_update: bool, + pub kms_key_access_mode: String, + pub service_account_id: Option, + pub expect_account_id: Option, + pub allow_existing: bool, + pub strict_preflight: bool, + pub validate_only: bool, + pub yes: bool, + pub wait_timeout_secs: u64, + pub poll_interval_secs: u64, + pub plerion_account_id: String, + /// Host the stack reports back to, e.g. `au.api.plerion.com`. + pub plerion_url: String, + /// Tenant label shown in the confirmation (region or endpoint host). + pub tenant_label: String, +} + +impl Default for OnboardOptions { + fn default() -> Self { + Self { + stack_name: DEFAULT_STACK_NAME.to_string(), + auto_update: true, + kms_key_access_mode: "ALL_KEYS".to_string(), + service_account_id: None, + expect_account_id: None, + allow_existing: false, + strict_preflight: false, + validate_only: false, + yes: false, + wait_timeout_secs: 1800, + poll_interval_secs: 15, + plerion_account_id: PLERION_ACCOUNT_ID_DEFAULT.to_string(), + plerion_url: "au.api.plerion.com".to_string(), + tenant_label: "au".to_string(), + } + } +} + +#[derive(Error, Debug)] +pub enum OnboardError { + #[error("{0}")] + InvalidOptions(String), + + #[error("Plerion API error: {0}")] + Plerion(#[from] PlerionError), + + #[error("Unexpected Plerion API response: {0}")] + UnexpectedResponse(String), + + #[error("AWS error: {0}")] + Aws(String), + + #[error("Preflight failed: {0}")] + PreflightFailed(String), + + #[error("Aborted: {0}")] + Aborted(String), + + #[error("Account already onboarded: {0}")] + AlreadyOnboarded(String), + + #[error("Deployment failed: {0}")] + DeployFailed(String), +} + +impl OnboardError { + /// Exit-code contract for `integrations add aws` (documented in the + /// command help): 1 config/API, 2 preflight/aborted, 3 deploy, + /// 4 already onboarded. + pub fn exit_code(&self) -> i32 { + match self { + OnboardError::InvalidOptions(_) + | OnboardError::Plerion(_) + | OnboardError::UnexpectedResponse(_) => 1, + OnboardError::Aws(_) + | OnboardError::PreflightFailed(_) + | OnboardError::Aborted(_) => 2, + OnboardError::DeployFailed(_) => 3, + OnboardError::AlreadyOnboarded(_) => 4, + } + } +} + +#[derive(Debug)] +pub enum OnboardOutcome { + /// Stack created; payload is the machine-readable result for stdout. + Completed(serde_json::Value), + /// --validate-only: the preflight report for stdout. + Validated(serde_json::Value), +} + +fn step(n: u8, total: u8, msg: &str) { + eprintln!("[{n}/{total}] {msg}"); +} + +fn validate_options(opts: &OnboardOptions) -> Result<(), OnboardError> { + if opts.auto_update && !opts.stack_name.starts_with("Plerion-") { + return Err(OnboardError::InvalidOptions(format!( + "stack name '{}' must start with 'Plerion-' while auto-update is enabled \ + (the auto-update role is IAM-scoped to stacks named Plerion*); \ + use --stack-name Plerion- or --no-auto-update", + opts.stack_name + ))); + } + for (flag, value) in [ + ("--expect-account-id", &opts.expect_account_id), + ("--service-account-id", &opts.service_account_id), + ] { + if let Some(v) = value { + if v.len() != 12 || !v.chars().all(|c| c.is_ascii_digit()) { + return Err(OnboardError::InvalidOptions(format!( + "{flag} must be a 12-digit AWS account ID (got '{v}')" + ))); + } + } + } + if !["ALL_KEYS", "SELECTED_KEYS"].contains(&opts.kms_key_access_mode.as_str()) { + return Err(OnboardError::InvalidOptions( + "--kms-key-access-mode must be ALL_KEYS or SELECTED_KEYS".to_string(), + )); + } + Ok(()) +} + +/// End-to-end onboarding of one AWS account. See SPEC-plerion-cli-onboard.md. +pub async fn run( + plerion: &PlerionClient, + aws: &dyn AwsApi, + ui: &mut dyn Ui, + opts: &OnboardOptions, +) -> Result { + validate_options(opts)?; + let total: u8 = 6; + + // [1/6] Tenant external ID (also serves as the API-key sanity check). + step(1, total, "Fetching tenant external ID"); + let external_id: ExternalIdResponse = + from_value(aws_endpoints::get_external_id(plerion).await?)?; + let external_id = external_id + .data + .and_then(|d| d.external_id) + .ok_or_else(|| OnboardError::UnexpectedResponse("missing data.externalId".to_string()))?; + + // [2/6] Pinned template + service account resolution. + step(2, total, "Resolving CloudFormation template and service account"); + let template: CfnTemplateResponse = from_value( + aws_endpoints::get_cloudformation_template(plerion, "AWSAccount").await?, + )?; + let template = template + .data + .ok_or_else(|| OnboardError::UnexpectedResponse("missing data".to_string()))?; + let template_url = template.template_url.clone().ok_or_else(|| { + OnboardError::UnexpectedResponse("missing data.templateURL".to_string()) + })?; + let template_version = template + .template_version + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let service_account_id = opts + .service_account_id + .clone() + .or(template.service_account_id.clone()) + .ok_or_else(|| { + OnboardError::InvalidOptions( + "the Plerion-managed CWPP service account ID could not be resolved \ + automatically; pass --service-account-id <12-digit-id> (from your \ + Plerion contact, or the console's Launch Stack link)" + .to_string(), + ) + })?; + + // [3/6] Preflight. + step(3, total, "Running preflight checks"); + let report = preflight::run(plerion, aws, opts, &template_url, &template_version).await?; + + if opts.validate_only { + let json = serde_json::to_value(&report) + .map_err(|e| OnboardError::UnexpectedResponse(e.to_string()))?; + eprintln!("Preflight passed — validation-only mode, nothing deployed."); + return Ok(OnboardOutcome::Validated(json)); + } + + // [4/6] Consolidated confirmation, then the short-lived registration token + // (minted last so its validity window is maximal). + step(4, total, "Confirming deployment"); + let alias = report + .account_alias + .clone() + .map(|a| format!(" (alias: {a})")) + .unwrap_or_default(); + let auto_update_label = if opts.auto_update { + "enabled (creates a guard-railed update role)" + } else { + "disabled" + }; + let prompt = format!( + "Onboard AWS account {}{} into Plerion tenant region {}?\n\ + \x20 Stack {} (template {})\n\ + \x20 Capabilities CSPM + CIEM + CWPP (Plerion-managed scanning, service acct {})\n\ + \x20 KMS access {}\n\ + \x20 Auto-update {}\n\ + Proceed?", + report.account_id, + alias, + opts.tenant_label, + opts.stack_name, + template_version, + service_account_id, + opts.kms_key_access_mode, + auto_update_label, + ); + if !opts.yes { + if !ui.is_interactive() { + return Err(OnboardError::Aborted( + "refusing to deploy without confirmation in a non-interactive session; \ + re-run with --yes (optionally with --expect-account-id as a guard)" + .to_string(), + )); + } + if !ui.confirm(&prompt) { + return Err(OnboardError::Aborted("declined at confirmation".to_string())); + } + } + + let token: TokenResponse = from_value(aws_endpoints::generate_token(plerion, None).await?)?; + let auth_token = token + .data + .and_then(|d| d.token) + .ok_or_else(|| OnboardError::UnexpectedResponse("missing data.token".to_string()))?; + + // [5/6] Create the stack. + step(5, total, &format!("Creating stack '{}'", opts.stack_name)); + let request = CreateStackRequest { + stack_name: opts.stack_name.clone(), + template_url: template_url.clone(), + parameters: vec![ + ("PlerionURL".to_string(), opts.plerion_url.clone()), + ("PlerionAccountId".to_string(), opts.plerion_account_id.clone()), + ("ExternalId".to_string(), external_id), + ("AuthToken".to_string(), auth_token), + ("Capabilities".to_string(), "ALL".to_string()), + ( + "WorkloadScanningType".to_string(), + "PlerionManagedServiceAccount".to_string(), + ), + ("ServiceAccountId".to_string(), service_account_id), + ("KMSKeyAccessMode".to_string(), opts.kms_key_access_mode.clone()), + ( + "EnableAutoUpdate".to_string(), + if opts.auto_update { "true" } else { "false" }.to_string(), + ), + ], + }; + let stack_id = aws.create_stack(&request).await?; + + // [6/6] Wait for completion. + step(6, total, "Waiting for stack to complete (typically 3-5 minutes)"); + let status = deploy::wait_for_stack(aws, &stack_id, opts).await?; + + let outputs = aws.stack_outputs(&stack_id).await.unwrap_or_default(); + let mut outputs_map = serde_json::Map::new(); + for o in &outputs { + outputs_map.insert(o.key.clone(), serde_json::Value::String(o.value.clone())); + } + let result = serde_json::json!({ + "accountId": report.account_id, + "stackName": opts.stack_name, + "stackId": stack_id, + "status": status, + "templateVersion": template_version, + "outputs": outputs_map, + }); + eprintln!( + "✓ Onboarded {} — findings appear in Plerion within ~10 minutes \ + (Settings > Integrations > Scans).", + report.account_id + ); + Ok(OnboardOutcome::Completed(result)) +} + +fn from_value(v: serde_json::Value) -> Result { + serde_json::from_value(v).map_err(|e| OnboardError::UnexpectedResponse(e.to_string())) +} diff --git a/src/onboard/plan.rs b/src/onboard/plan.rs new file mode 100644 index 0000000..bd7c3a3 --- /dev/null +++ b/src/onboard/plan.rs @@ -0,0 +1,54 @@ +use crate::onboard::{OnboardOptions, REQUIRED_ACTIONS}; + +/// Render the fully offline `--dry-run` plan: every call the command would +/// make and the complete CreateStack parameter set, with placeholders for +/// values fetched at run time. The AuthToken is never rendered. +pub fn render(opts: &OnboardOptions) -> String { + let service_account = opts + .service_account_id + .clone() + .unwrap_or_else(|| "".to_string()); + let auto_update = if opts.auto_update { "true" } else { "false" }; + format!( + "plerion integrations add aws — dry run (no network calls were made)\n\ + \n\ + Plerion API calls (Bearer-authenticated, tenant {tenant}):\n\ + \x20 1. GET /v1/tenant/external-id\n\ + \x20 2. GET /v1/tenant/cloudformation-templates?type=AWSAccount\n\ + \x20 3. POST /v1/tenant/integrations/token (after confirmation; short-lived)\n\ + \n\ + AWS calls (deployer credentials):\n\ + \x20 preflight: sts:GetCallerIdentity, iam:ListAccountAliases (best-effort),\n\ + \x20 cloudformation:DescribeStacks, iam:ListRoles,\n\ + \x20 iam:GetRole + iam:SimulatePrincipalPolicy (advisory, {n} actions),\n\ + \x20 cloudformation:GetTemplateSummary\n\ + \x20 deploy: cloudformation:CreateStack (CAPABILITY_NAMED_IAM, OnFailure=ROLLBACK),\n\ + \x20 cloudformation:DescribeStacks (poll), DescribeStackEvents (on failure)\n\ + \n\ + CreateStack parameters:\n\ + \x20 StackName {stack}\n\ + \x20 TemplateURL \n\ + \x20 PlerionURL {plerion_url}\n\ + \x20 PlerionAccountId {plerion_account}\n\ + \x20 ExternalId \n\ + \x20 AuthToken \n\ + \x20 Capabilities ALL\n\ + \x20 WorkloadScanningType PlerionManagedServiceAccount\n\ + \x20 ServiceAccountId {service_account}\n\ + \x20 KMSKeyAccessMode {kms}\n\ + \x20 EnableAutoUpdate {auto_update}\n\ + \n\ + Equivalent AWS CLI deploy step:\n\ + \x20 aws cloudformation create-stack --stack-name {stack} \\\n\ + \x20 --template-url --capabilities CAPABILITY_NAMED_IAM \\\n\ + \x20 --parameters ParameterKey=PlerionURL,ParameterValue={plerion_url} ...\n", + tenant = opts.tenant_label, + n = REQUIRED_ACTIONS.len(), + stack = opts.stack_name, + plerion_url = opts.plerion_url, + plerion_account = opts.plerion_account_id, + service_account = service_account, + kms = opts.kms_key_access_mode, + auto_update = auto_update, + ) +} diff --git a/src/onboard/preflight.rs b/src/onboard/preflight.rs new file mode 100644 index 0000000..4dfc7ce --- /dev/null +++ b/src/onboard/preflight.rs @@ -0,0 +1,180 @@ +use crate::api::client::PlerionClient; +use crate::api::endpoints::integrations::list_integrations; +use crate::onboard::aws_api::{AwsApi, SimResult}; +use crate::onboard::{OnboardError, OnboardOptions, REQUIRED_ACTIONS}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreflightReport { + pub account_id: String, + pub caller_arn: String, + pub account_alias: Option, + pub template_version: String, + pub template_description: Option, + /// Plerion IAM roles found in the account (any stack name). + pub existing_plerion_roles: Vec, + /// True when the tenant already lists an integration for this account. + pub existing_integration: bool, + /// Actions the IAM simulator reported as denied. Advisory: the simulator + /// has known false negatives with AWS Identity Center (SSO) principals. + pub simulation_denied: Option>, +} + +/// Steps 3a-3e. Returns a report on success; fails with typed errors that map +/// to the command's exit codes. +pub async fn run( + plerion: &PlerionClient, + aws: &dyn AwsApi, + opts: &OnboardOptions, + template_url: &str, + template_version: &str, +) -> Result { + // 3a. Who are we deploying as, and is it the intended account? + let caller = aws.caller_identity().await?; + if let Some(expected) = &opts.expect_account_id { + if expected != &caller.account { + return Err(OnboardError::PreflightFailed(format!( + "credentials belong to account {}, but --expect-account-id is {} — wrong AWS profile?", + caller.account, expected + ))); + } + } + let account_alias = aws.account_alias().await.unwrap_or(None); + + // 3b. Same-name stack conflict. + if let Some(status) = aws.find_stack(&opts.stack_name).await? { + if status == "ROLLBACK_COMPLETE" { + return Err(OnboardError::PreflightFailed(format!( + "stack '{}' exists in ROLLBACK_COMPLETE (a previous failed create); delete it \ + first: aws cloudformation delete-stack --stack-name {}", + opts.stack_name, opts.stack_name + ))); + } + return Err(OnboardError::PreflightFailed(format!( + "stack '{}' already exists (status: {})", + opts.stack_name, status + ))); + } + + // 3c. Already onboarded? Check both sides: IAM roles in the account (any + // stack name — console stacks use different names) and the tenant's own + // integration list. + let existing_roles = aws.list_roles_matching("PlerionAccessRole").await?; + let existing_integration = tenant_has_integration(plerion, &caller.account).await?; + if !existing_roles.is_empty() || existing_integration { + let mut what = Vec::new(); + if !existing_roles.is_empty() { + what.push(format!("Plerion IAM role(s): {}", existing_roles.join(", "))); + } + if existing_integration { + what.push("an existing integration for this account in the tenant".to_string()); + } + let detail = what.join(" and "); + if !opts.allow_existing { + return Err(OnboardError::AlreadyOnboarded(format!( + "found {detail}. Deploying again creates a second integration (sometimes \ + intentional, e.g. a different tenant). Re-run with --allow-existing to proceed." + ))); + } + eprintln!(" ! proceeding despite {detail} (--allow-existing)"); + } + + // 3d. Advisory permission simulation. False denials are a known issue for + // Identity Center (SSO) principals, so denials warn rather than fail + // unless --strict-preflight. + let simulation_denied = match simulate_deployer(aws, &caller.arn, &caller.account).await { + Ok(results) => { + let denied: Vec = results + .iter() + .filter(|r| !r.allowed) + .map(|r| r.action.clone()) + .collect(); + if !denied.is_empty() { + if opts.strict_preflight { + return Err(OnboardError::PreflightFailed(format!( + "IAM simulation reports denied actions ({}) and --strict-preflight is set", + denied.join(", ") + ))); + } + eprintln!( + " ! IAM simulation reports {} action(s) as denied: {}", + denied.len(), + denied.join(", ") + ); + eprintln!( + " ! this is often a FALSE NEGATIVE (known with AWS Identity Center / SSO \ + roles); continuing — a real permission gap fails cleanly at deploy with a \ + rollback. Use --strict-preflight to make this fatal." + ); + } + Some(denied) + } + Err(_) => { + eprintln!( + " ! could not run iam:SimulatePrincipalPolicy — skipping the permission \ + check (deployment may still fail if the deployer lacks the minimum policy)" + ); + None + } + }; + + // 3e. Template validation (GetTemplateSummary is covered by the minimum + // deployer policy, unlike ValidateTemplate). + let summary = aws.template_summary(template_url).await?; + + Ok(PreflightReport { + account_id: caller.account, + caller_arn: caller.arn, + account_alias, + template_version: template_version.to_string(), + template_description: summary.description, + existing_plerion_roles: existing_roles, + existing_integration, + simulation_denied, + }) +} + +async fn tenant_has_integration( + plerion: &PlerionClient, + account_id: &str, +) -> Result { + let mut cursor: Option = None; + loop { + let resp = list_integrations(plerion, Some(1000), cursor.as_deref(), false).await?; + if resp + .data + .iter() + .any(|i| i.aws_account_id.as_deref() == Some(account_id)) + { + return Ok(true); + } + if !resp.meta.has_next_page.unwrap_or(false) { + return Ok(false); + } + cursor = resp.meta.cursor; + } +} + +async fn simulate_deployer( + aws: &dyn AwsApi, + caller_arn: &str, + account: &str, +) -> Result, OnboardError> { + // simulate-principal-policy needs an IAM user/role ARN, not the STS + // assumed-role ARN. Resolve via GetRole so path'd roles (Identity Center) + // get their real ARN. + let source_arn = if caller_arn.contains(":assumed-role/") { + let role_name = caller_arn + .split('/') + .nth(1) + .ok_or_else(|| OnboardError::Aws("unparseable assumed-role ARN".to_string()))?; + aws.role_arn(role_name) + .await + .unwrap_or_else(|_| format!("arn:aws:iam::{account}:role/{role_name}")) + } else { + caller_arn.to_string() + }; + let actions: Vec = REQUIRED_ACTIONS.iter().map(|s| s.to_string()).collect(); + aws.simulate(&source_arn, &actions).await +} diff --git a/src/onboard/sdk.rs b/src/onboard/sdk.rs new file mode 100644 index 0000000..866c948 --- /dev/null +++ b/src/onboard/sdk.rs @@ -0,0 +1,240 @@ +use crate::onboard::aws_api::*; +use crate::onboard::OnboardError; +use aws_config::BehaviorVersion; + +/// Production `AwsApi` backed by the AWS SDK. The only file in the crate that +/// imports aws-sdk-* types. +pub struct SdkAws { + sts: aws_sdk_sts::Client, + iam: aws_sdk_iam::Client, + cfn: aws_sdk_cloudformation::Client, +} + +impl SdkAws { + pub async fn new(profile: Option<&str>, region: Option<&str>) -> Self { + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(p) = profile { + loader = loader.profile_name(p); + } + if let Some(r) = region { + loader = loader.region(aws_config::Region::new(r.to_string())); + } + let sdk_config = loader.load().await; + Self { + sts: aws_sdk_sts::Client::new(&sdk_config), + iam: aws_sdk_iam::Client::new(&sdk_config), + cfn: aws_sdk_cloudformation::Client::new(&sdk_config), + } + } +} + +fn aws_err(context: &str, e: E) -> OnboardError +where + E: std::error::Error + Send + Sync + 'static, +{ + // Walk the source chain so service-level messages (AccessDenied etc.) + // surface instead of the SDK's generic "service error". + let mut msg = e.to_string(); + let mut source = std::error::Error::source(&e); + while let Some(s) = source { + msg = format!("{msg}: {s}"); + source = s.source(); + } + OnboardError::Aws(format!("{context}: {msg}")) +} + +#[async_trait::async_trait] +impl AwsApi for SdkAws { + async fn caller_identity(&self) -> Result { + let resp = self + .sts + .get_caller_identity() + .send() + .await + .map_err(|e| aws_err("sts:GetCallerIdentity", e))?; + Ok(CallerIdentity { + account: resp.account().unwrap_or_default().to_string(), + arn: resp.arn().unwrap_or_default().to_string(), + }) + } + + async fn account_alias(&self) -> Result, OnboardError> { + // Best-effort: iam:ListAccountAliases is not in the minimum policy. + Ok(self + .iam + .list_account_aliases() + .send() + .await + .ok() + .and_then(|r| r.account_aliases().first().cloned())) + } + + async fn find_stack(&self, name: &str) -> Result, OnboardError> { + match self.cfn.describe_stacks().stack_name(name).send().await { + Ok(resp) => Ok(resp + .stacks() + .first() + .and_then(|s| s.stack_status()) + .map(|s| s.as_str().to_string())), + Err(e) => { + let msg = format!("{e:?}"); + if msg.contains("does not exist") { + Ok(None) + } else { + Err(aws_err("cloudformation:DescribeStacks", e)) + } + } + } + } + + async fn list_roles_matching(&self, needle: &str) -> Result, OnboardError> { + let mut matches = Vec::new(); + let mut marker: Option = None; + loop { + let resp = self + .iam + .list_roles() + .set_marker(marker.clone()) + .send() + .await + .map_err(|e| aws_err("iam:ListRoles", e))?; + matches.extend( + resp.roles() + .iter() + .filter(|r| r.role_name().contains(needle)) + .map(|r| r.role_name().to_string()), + ); + if resp.is_truncated() { + marker = resp.marker().map(String::from); + } else { + break; + } + } + Ok(matches) + } + + async fn role_arn(&self, role_name: &str) -> Result { + let resp = self + .iam + .get_role() + .role_name(role_name) + .send() + .await + .map_err(|e| aws_err("iam:GetRole", e))?; + resp.role() + .map(|r| r.arn().to_string()) + .ok_or_else(|| OnboardError::Aws("iam:GetRole returned no role".to_string())) + } + + async fn simulate( + &self, + policy_source_arn: &str, + actions: &[String], + ) -> Result, OnboardError> { + let resp = self + .iam + .simulate_principal_policy() + .policy_source_arn(policy_source_arn) + .set_action_names(Some(actions.to_vec())) + .send() + .await + .map_err(|e| aws_err("iam:SimulatePrincipalPolicy", e))?; + Ok(resp + .evaluation_results() + .iter() + .map(|r| SimResult { + action: r.eval_action_name().to_string(), + allowed: r.eval_decision().as_str() == "allowed", + }) + .collect()) + } + + async fn template_summary(&self, template_url: &str) -> Result { + let resp = self + .cfn + .get_template_summary() + .template_url(template_url) + .send() + .await + .map_err(|e| aws_err("cloudformation:GetTemplateSummary", e))?; + Ok(TemplateSummary { + description: resp.description().map(String::from), + }) + } + + async fn create_stack(&self, req: &CreateStackRequest) -> Result { + use aws_sdk_cloudformation::types::{Capability, OnFailure, Parameter}; + let params = req + .parameters + .iter() + .map(|(k, v)| { + Parameter::builder() + .parameter_key(k) + .parameter_value(v) + .build() + }) + .collect::>(); + let resp = self + .cfn + .create_stack() + .stack_name(&req.stack_name) + .template_url(&req.template_url) + .set_parameters(Some(params)) + .capabilities(Capability::CapabilityNamedIam) + .on_failure(OnFailure::Rollback) + .send() + .await + .map_err(|e| aws_err("cloudformation:CreateStack", e))?; + resp.stack_id() + .map(String::from) + .ok_or_else(|| OnboardError::Aws("CreateStack returned no StackId".to_string())) + } + + async fn stack_failure_events(&self, name: &str) -> Result, OnboardError> { + let resp = self + .cfn + .describe_stack_events() + .stack_name(name) + .send() + .await + .map_err(|e| aws_err("cloudformation:DescribeStackEvents", e))?; + Ok(resp + .stack_events() + .iter() + .filter(|e| { + e.resource_status() + .map(|s| s.as_str().contains("FAILED")) + .unwrap_or(false) + }) + .map(|e| StackEvent { + logical_id: e.logical_resource_id().unwrap_or_default().to_string(), + status: e + .resource_status() + .map(|s| s.as_str().to_string()) + .unwrap_or_default(), + reason: e.resource_status_reason().map(String::from), + }) + .collect()) + } + + async fn stack_outputs(&self, name: &str) -> Result, OnboardError> { + let resp = self + .cfn + .describe_stacks() + .stack_name(name) + .send() + .await + .map_err(|e| aws_err("cloudformation:DescribeStacks", e))?; + Ok(resp + .stacks() + .first() + .map(|s| s.outputs()) + .unwrap_or_default() + .iter() + .map(|o| StackOutput { + key: o.output_key().unwrap_or_default().to_string(), + value: o.output_value().unwrap_or_default().to_string(), + }) + .collect()) + } +} diff --git a/src/onboard/ui.rs b/src/onboard/ui.rs new file mode 100644 index 0000000..a6ffbb4 --- /dev/null +++ b/src/onboard/ui.rs @@ -0,0 +1,27 @@ +use std::io::{IsTerminal, Write}; + +/// Interaction seam for the onboarding flow so tests can script confirmations. +pub trait Ui: Send { + fn is_interactive(&self) -> bool; + /// Ask a yes/no question; returns true only on an explicit yes. + fn confirm(&mut self, prompt: &str) -> bool; +} + +/// Real terminal UI (matches the hand-rolled prompt style in `configure`). +pub struct TtyUi; + +impl Ui for TtyUi { + fn is_interactive(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn confirm(&mut self, prompt: &str) -> bool { + eprint!("{prompt} [y/N] "); + let _ = std::io::stderr().flush(); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + return false; + } + matches!(line.trim().to_lowercase().as_str(), "y" | "yes") + } +} diff --git a/tests/aws_test.rs b/tests/aws_test.rs index 2586080..535dbd9 100644 --- a/tests/aws_test.rs +++ b/tests/aws_test.rs @@ -36,22 +36,44 @@ async fn test_get_cloudformation_template() { } #[tokio::test] -async fn test_generate_token() { +async fn test_generate_token_for_integration() { let mut server = Server::new_async().await; let body = serde_json::json!({ "data": { "token": "tmp-token-xyz" } }); let mock = server .mock("POST", "/v1/tenant/integrations/token") + .match_body(mockito::Matcher::JsonString( + r#"{"integrationId":"int-123"}"#.to_string(), + )) .with_status(200) .with_body(body.to_string()) .create_async() .await; let client = PlerionClient::with_base_url(&server.url(), "key").unwrap(); - let resp = aws::generate_token(&client, "int-123").await.unwrap(); + let resp = aws::generate_token(&client, Some("int-123")).await.unwrap(); assert_eq!(resp["data"]["token"], "tmp-token-xyz"); mock.assert_async().await; } +#[tokio::test] +async fn test_generate_token_bare_post_for_onboarding() { + let mut server = Server::new_async().await; + let body = serde_json::json!({ "data": { "token": "tmp-onboard-token" } }); + // The onboarding variant must NOT send an integrationId body. + let mock = server + .mock("POST", "/v1/tenant/integrations/token") + .match_body(mockito::Matcher::Exact(String::new())) + .with_status(200) + .with_body(body.to_string()) + .create_async() + .await; + + let client = PlerionClient::with_base_url(&server.url(), "key").unwrap(); + let resp = aws::generate_token(&client, None).await.unwrap(); + assert_eq!(resp["data"]["token"], "tmp-onboard-token"); + mock.assert_async().await; +} + #[tokio::test] async fn test_get_external_id_forbidden() { let mut server = Server::new_async().await; diff --git a/tests/cli_onboard_test.rs b/tests/cli_onboard_test.rs new file mode 100644 index 0000000..be4078f --- /dev/null +++ b/tests/cli_onboard_test.rs @@ -0,0 +1,118 @@ +use std::process::Command; + +/// Subprocess tests for `plerion integrations add aws`. Only offline paths +/// (--dry-run, flag validation) run here; the orchestration itself is covered +/// at the lib level in onboard_test.rs where AWS is mockable. +fn run_plerion(args: &[&str]) -> std::process::Output { + let binary = env!("CARGO_BIN_EXE_plerion"); + Command::new(binary) + .args(args) + .env("PLERION_API_KEY", "test-key") + .env("PLERION_REGION", "au") + .env("NO_COLOR", "1") + .output() + .expect("failed to execute plerion binary") +} + +#[test] +fn test_cli_add_aws_dry_run_is_offline_and_redacts_token() { + let output = run_plerion(&[ + "integrations", + "add", + "aws", + "--dry-run", + "--service-account-id", + "222222222222", + ]); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(stdout.contains("dry run"), "{stdout}"); + assert!(stdout.contains("Plerion-Integration"), "{stdout}"); + assert!(stdout.contains("PlerionManagedServiceAccount"), "{stdout}"); + assert!(stdout.contains("222222222222"), "{stdout}"); + assert!(stdout.contains("redacted"), "{stdout}"); + // The real token value can never appear: dry-run makes no network calls. + assert!(!stdout.contains("tmp-"), "{stdout}"); + assert!(stdout.contains("au.api.plerion.com"), "{stdout}"); +} + +#[test] +fn test_cli_add_aws_dry_run_no_auto_update() { + let output = run_plerion(&[ + "integrations", + "add", + "aws", + "--dry-run", + "--no-auto-update", + "--stack-name", + "CustomStack", + ]); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + assert!(stdout.contains("EnableAutoUpdate false"), "{stdout}"); + assert!(stdout.contains("CustomStack"), "{stdout}"); +} + +#[test] +fn test_cli_add_aws_rejects_bad_kms_mode() { + let output = run_plerion(&[ + "integrations", + "add", + "aws", + "--dry-run", + "--kms-key-access-mode", + "SOME_KEYS", + ]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("SOME_KEYS"), "{stderr}"); +} + +#[test] +fn test_cli_add_aws_stack_name_prefix_rule_exit_code() { + // Not dry-run: option validation runs first in onboard::run and must exit 1 + // before any network access is attempted... except validation happens after + // client construction, which is offline. Use --validate-only with a bad + // stack name; the InvalidOptions error maps to exit code 1. + let output = run_plerion(&[ + "integrations", + "add", + "aws", + "--validate-only", + "--stack-name", + "NotPlerion", + "--service-account-id", + "222222222222", + "--endpoint-url", + "http://127.0.0.1:1", // unreachable; must fail before any request + ]); + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Plerion-"), "{stderr}"); +} + +#[test] +fn test_cli_add_aws_help_lists_flags() { + let output = run_plerion(&["integrations", "add", "aws", "--help"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + for flag in [ + "--aws-profile", + "--aws-region", + "--expect-account-id", + "--validate-only", + "--dry-run", + "--allow-existing", + "--strict-preflight", + "--no-auto-update", + ] { + assert!(stdout.contains(flag), "missing {flag} in help: {stdout}"); + } + // Hidden escape hatch stays hidden. + assert!(!stdout.contains("--plerion-account-id"), "{stdout}"); +} diff --git a/tests/onboard_test.rs b/tests/onboard_test.rs new file mode 100644 index 0000000..c342782 --- /dev/null +++ b/tests/onboard_test.rs @@ -0,0 +1,468 @@ +use mockito::Server; +use plerion::api::client::PlerionClient; +use plerion::onboard::aws_api::*; +use plerion::onboard::ui::Ui; +use plerion::onboard::{run, OnboardError, OnboardOptions, OnboardOutcome}; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +// --------------------------------------------------------------------------- +// Scripted fakes +// --------------------------------------------------------------------------- + +struct MockAws { + account: String, + arn: String, + alias: Option, + /// Status returned when preflight checks the stack NAME (None = no stack). + existing_stack: Option, + roles: Vec, + /// Actions the simulator reports as denied. + sim_denied: Vec, + sim_error: bool, + /// Statuses returned, in order, when polling the created stack (by ID). + poll_statuses: Mutex>, + failure_events: Vec, + outputs: Vec, + create_called: AtomicBool, +} + +impl Default for MockAws { + fn default() -> Self { + Self { + account: "111111111111".to_string(), + arn: "arn:aws:sts::111111111111:assumed-role/AWSReservedSSO_Admin_abc/session".to_string(), + alias: Some("acme-prod".to_string()), + existing_stack: None, + roles: vec![], + sim_denied: vec![], + sim_error: false, + poll_statuses: Mutex::new(VecDeque::from(vec![ + "CREATE_IN_PROGRESS".to_string(), + "CREATE_COMPLETE".to_string(), + ])), + failure_events: vec![], + outputs: vec![StackOutput { + key: "PlerionAccessRoleArn".to_string(), + value: "arn:aws:iam::111111111111:role/uuid-PlerionAccessRole".to_string(), + }], + create_called: AtomicBool::new(false), + } + } +} + +#[async_trait::async_trait] +impl AwsApi for MockAws { + async fn caller_identity(&self) -> Result { + Ok(CallerIdentity { + account: self.account.clone(), + arn: self.arn.clone(), + }) + } + async fn account_alias(&self) -> Result, OnboardError> { + Ok(self.alias.clone()) + } + async fn find_stack(&self, name: &str) -> Result, OnboardError> { + if name.starts_with("arn:") { + // Poll loop looks the created stack up by its StackId. + let mut q = self.poll_statuses.lock().unwrap(); + let status = q.front().cloned(); + if q.len() > 1 { + q.pop_front(); + } + Ok(status) + } else { + Ok(self.existing_stack.clone()) + } + } + async fn list_roles_matching(&self, _needle: &str) -> Result, OnboardError> { + Ok(self.roles.clone()) + } + async fn role_arn(&self, role_name: &str) -> Result { + Ok(format!( + "arn:aws:iam::{}:role/aws-reserved/sso.amazonaws.com/{}", + self.account, role_name + )) + } + async fn simulate( + &self, + _policy_source_arn: &str, + actions: &[String], + ) -> Result, OnboardError> { + if self.sim_error { + return Err(OnboardError::Aws("simulate unavailable".to_string())); + } + Ok(actions + .iter() + .map(|a| SimResult { + action: a.clone(), + allowed: !self.sim_denied.contains(a), + }) + .collect()) + } + async fn template_summary(&self, _template_url: &str) -> Result { + Ok(TemplateSummary { + description: Some("Grants Plerion access".to_string()), + }) + } + async fn create_stack(&self, req: &CreateStackRequest) -> Result { + self.create_called.store(true, Ordering::SeqCst); + assert!( + !req.parameters.iter().any(|(_, v)| v.is_empty()), + "no CreateStack parameter may be empty" + ); + Ok(format!( + "arn:aws:cloudformation:us-east-1:{}:stack/{}/uuid", + self.account, req.stack_name + )) + } + async fn stack_failure_events(&self, _name: &str) -> Result, OnboardError> { + Ok(self.failure_events.clone()) + } + async fn stack_outputs(&self, _name: &str) -> Result, OnboardError> { + Ok(self.outputs.clone()) + } +} + +struct ScriptedUi { + interactive: bool, + answers: VecDeque, +} + +impl ScriptedUi { + fn yes() -> Self { + Self { interactive: true, answers: VecDeque::from(vec![true]) } + } + fn no() -> Self { + Self { interactive: true, answers: VecDeque::from(vec![false]) } + } + fn non_interactive() -> Self { + Self { interactive: false, answers: VecDeque::new() } + } +} + +impl Ui for ScriptedUi { + fn is_interactive(&self) -> bool { + self.interactive + } + fn confirm(&mut self, _prompt: &str) -> bool { + self.answers.pop_front().unwrap_or(false) + } +} + +// --------------------------------------------------------------------------- +// Plerion-side mock server +// --------------------------------------------------------------------------- + +async fn plerion_server(with_existing_integration: bool) -> (mockito::ServerGuard, PlerionClient) { + let mut server = Server::new_async().await; + server + .mock("GET", "/v1/tenant/external-id") + .with_status(200) + .with_body(r#"{"data":{"externalId":"ext-123"}}"#) + .create_async() + .await; + server + .mock("GET", "/v1/tenant/cloudformation-templates") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_body(r#"{"data":{"templateURL":"https://s3.example/template.yaml","templateVersion":"v33"}}"#) + .create_async() + .await; + let integrations = if with_existing_integration { + r#"{"data":[{"integrationId":"int-1","awsAccountId":"111111111111"}],"meta":{"cursor":null,"hasNextPage":false}}"# + } else { + r#"{"data":[],"meta":{"cursor":null,"hasNextPage":false}}"# + }; + server + .mock("GET", "/v1/tenant/integrations") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_body(integrations) + .create_async() + .await; + server + .mock("POST", "/v1/tenant/integrations/token") + .with_status(200) + .with_body(r#"{"data":{"token":"tmp-onboarding-token"}}"#) + .create_async() + .await; + let client = PlerionClient::with_base_url(&server.url(), "key").unwrap(); + (server, client) +} + +fn fast_opts() -> OnboardOptions { + OnboardOptions { + service_account_id: Some("222222222222".to_string()), + poll_interval_secs: 0, + wait_timeout_secs: 5, + ..OnboardOptions::default() + } +} + +// --------------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_happy_path_completes_with_outputs() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::yes(); + + let outcome = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap(); + match outcome { + OnboardOutcome::Completed(v) => { + assert_eq!(v["accountId"], "111111111111"); + assert_eq!(v["status"], "CREATE_COMPLETE"); + assert_eq!(v["templateVersion"], "v33"); + assert!(v["outputs"]["PlerionAccessRoleArn"] + .as_str() + .unwrap() + .contains("PlerionAccessRole")); + } + _ => panic!("expected Completed"), + } + assert!(aws.create_called.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn test_expect_account_id_mismatch_fails_preflight() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { + expect_account_id: Some("999999999999".to_string()), + ..fast_opts() + }; + + let err = run(&client, &aws, &mut ui, &opts).await.unwrap_err(); + assert!(matches!(err, OnboardError::PreflightFailed(_)), "{err}"); + assert_eq!(err.exit_code(), 2); + assert!(!aws.create_called.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn test_rollback_complete_stack_gets_delete_hint() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + existing_stack: Some("ROLLBACK_COMPLETE".to_string()), + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert_eq!(err.exit_code(), 2); + assert!(err.to_string().contains("delete-stack"), "{err}"); +} + +#[tokio::test] +async fn test_existing_roles_abort_without_allow_existing() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + roles: vec!["uuid-PlerionAccessRole".to_string()], + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert!(matches!(err, OnboardError::AlreadyOnboarded(_)), "{err}"); + assert_eq!(err.exit_code(), 4); +} + +#[tokio::test] +async fn test_existing_integration_detected_plerion_side() { + let (_server, client) = plerion_server(true).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::yes(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert!(matches!(err, OnboardError::AlreadyOnboarded(_)), "{err}"); + assert_eq!(err.exit_code(), 4); +} + +#[tokio::test] +async fn test_allow_existing_proceeds_past_duplicates() { + let (_server, client) = plerion_server(true).await; + let aws = MockAws { + roles: vec!["uuid-PlerionAccessRole".to_string()], + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { allow_existing: true, ..fast_opts() }; + + let outcome = run(&client, &aws, &mut ui, &opts).await.unwrap(); + assert!(matches!(outcome, OnboardOutcome::Completed(_))); +} + +#[tokio::test] +async fn test_simulation_denials_are_advisory_by_default() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + sim_denied: vec!["cloudformation:CreateStack".to_string()], + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + + let outcome = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap(); + assert!(matches!(outcome, OnboardOutcome::Completed(_))); +} + +#[tokio::test] +async fn test_simulation_denials_fatal_with_strict_preflight() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + sim_denied: vec!["cloudformation:CreateStack".to_string()], + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { strict_preflight: true, ..fast_opts() }; + + let err = run(&client, &aws, &mut ui, &opts).await.unwrap_err(); + assert!(matches!(err, OnboardError::PreflightFailed(_)), "{err}"); + assert_eq!(err.exit_code(), 2); +} + +#[tokio::test] +async fn test_simulation_unavailable_skips_check() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { sim_error: true, ..MockAws::default() }; + let mut ui = ScriptedUi::yes(); + + let outcome = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap(); + assert!(matches!(outcome, OnboardOutcome::Completed(_))); +} + +#[tokio::test] +async fn test_validate_only_stops_before_confirmation_and_deploy() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + // A UI that would panic if asked: validate-only must never confirm. + let mut ui = ScriptedUi::non_interactive(); + let opts = OnboardOptions { validate_only: true, ..fast_opts() }; + + let outcome = run(&client, &aws, &mut ui, &opts).await.unwrap(); + match outcome { + OnboardOutcome::Validated(report) => { + assert_eq!(report["accountId"], "111111111111"); + assert_eq!(report["templateVersion"], "v33"); + assert_eq!(report["existingIntegration"], false); + } + _ => panic!("expected Validated"), + } + assert!(!aws.create_called.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn test_non_interactive_without_yes_aborts() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::non_interactive(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert!(matches!(err, OnboardError::Aborted(_)), "{err}"); + assert_eq!(err.exit_code(), 2); + assert!(!aws.create_called.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn test_confirmation_declined_aborts() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::no(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert!(matches!(err, OnboardError::Aborted(_)), "{err}"); + assert!(!aws.create_called.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn test_yes_skips_confirmation() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::non_interactive(); + let opts = OnboardOptions { yes: true, ..fast_opts() }; + + let outcome = run(&client, &aws, &mut ui, &opts).await.unwrap(); + assert!(matches!(outcome, OnboardOutcome::Completed(_))); +} + +#[tokio::test] +async fn test_deploy_failure_dumps_events_with_token_hint() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + poll_statuses: Mutex::new(VecDeque::from(vec![ + "CREATE_IN_PROGRESS".to_string(), + "ROLLBACK_COMPLETE".to_string(), + ])), + failure_events: vec![StackEvent { + logical_id: "PlerionAPICall".to_string(), + status: "CREATE_FAILED".to_string(), + reason: Some("Received response status [FAILED]".to_string()), + }], + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + + let err = run(&client, &aws, &mut ui, &fast_opts()).await.unwrap_err(); + assert!(matches!(err, OnboardError::DeployFailed(_)), "{err}"); + assert_eq!(err.exit_code(), 3); + let msg = err.to_string(); + assert!(msg.contains("PlerionAPICall"), "{msg}"); + assert!(msg.contains("token"), "{msg}"); +} + +#[tokio::test] +async fn test_wait_timeout() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws { + poll_statuses: Mutex::new(VecDeque::from(vec!["CREATE_IN_PROGRESS".to_string()])), + ..MockAws::default() + }; + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { wait_timeout_secs: 0, ..fast_opts() }; + + let err = run(&client, &aws, &mut ui, &opts).await.unwrap_err(); + assert!(matches!(err, OnboardError::DeployFailed(_)), "{err}"); + assert!(err.to_string().contains("timed out"), "{err}"); +} + +#[tokio::test] +async fn test_stack_name_prefix_rule() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { + stack_name: "MyStack".to_string(), + ..fast_opts() + }; + + let err = run(&client, &aws, &mut ui, &opts).await.unwrap_err(); + assert!(matches!(err, OnboardError::InvalidOptions(_)), "{err}"); + assert_eq!(err.exit_code(), 1); + + // Allowed when auto-update is off. + let opts = OnboardOptions { + stack_name: "MyStack".to_string(), + auto_update: false, + ..fast_opts() + }; + let outcome = run(&client, &aws, &mut ui, &opts).await; + assert!(outcome.is_ok()); +} + +#[tokio::test] +async fn test_missing_service_account_id_gives_guidance() { + let (_server, client) = plerion_server(false).await; + let aws = MockAws::default(); + let mut ui = ScriptedUi::yes(); + let opts = OnboardOptions { + service_account_id: None, + ..fast_opts() + }; + + let err = run(&client, &aws, &mut ui, &opts).await.unwrap_err(); + assert!(matches!(err, OnboardError::InvalidOptions(_)), "{err}"); + assert!(err.to_string().contains("--service-account-id"), "{err}"); +}