airs-cli 0.8.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! The shared machinery of the red team writes (DESIGN §15.3): the unauthenticated
//! client (a confirmation is asked before the token is fetched), the D81 confirmation,
//! the one-attempt write with its outcome and hint lines (D133), the interruption
//! contexts before and after a write is sent, the bounded input files (D143) and the
//! request-body validation (D116).

use super::Invocation;
use crate::clients::{RedTeamClients, TOKEN_BUDGET};
use crate::error::{CliError, InterruptContext};
use crate::json::OValue;
use crate::output::terminal_safe;
use crate::writes::{self, Change};
use scm::airs::red_team::RedTeamClient;
use std::ffi::OsStr;

/// What an interruption reports for one write: before it was sent and after.
#[derive(Clone, Debug)]
pub struct Contexts {
    pub not_sent: InterruptContext,
    pub sent: InterruptContext,
}

impl Contexts {
    /// `nothing was changed` / `the change may already have been applied (outcome
    /// unknown)` (§15.3 "Interruption").
    pub fn plain() -> Self {
        Self {
            not_sent: InterruptContext::NothingChanged,
            sent: InterruptContext::ChangeMayBeApplied,
        }
    }

    /// A write with its own text once sent (scan start, abort, EULA accept).
    pub fn sent(text: impl Into<String>) -> Self {
        Self {
            not_sent: InterruptContext::NothingChanged,
            sent: InterruptContext::Context(text.into()),
        }
    }

    /// Before the write: a read or the token request that was interrupted.
    pub fn before(&self, error: CliError) -> CliError {
        match error {
            CliError::Interrupted(
                InterruptContext::NoDataWritten | InterruptContext::NothingChanged,
            ) => CliError::Interrupted(self.not_sent.clone()),
            other => other,
        }
    }
}

/// The red team client of the selected tenant, not yet authenticated, and the tenant's
/// `defaultOutput`.
pub struct Unauthenticated {
    pub clients: RedTeamClients,
    pub default_output: Option<String>,
    /// The selected tenant's store name and `mgmtTsgId` (backups, §15.18).
    pub tenant: String,
    pub tsg: String,
}

impl Unauthenticated {
    pub fn client(&self) -> &RedTeamClient {
        &self.clients.red_team
    }
}

impl Invocation {
    /// Load the tenant, check the management credentials (§3.5) and build the red team
    /// client, without any request (a confirmation may follow, §15.3).
    pub fn red_team_unauthenticated(&mut self) -> Result<Unauthenticated, CliError> {
        let context = self.load_tenant()?;
        let default_output = context.config.default_output().map(str::to_owned);
        crate::tenant::config::require_management(&context.config, Some(context.name()))?;
        let clients = RedTeamClients::build(
            &context.config,
            &self.session,
            &self.secrets,
            self.debug.as_ref(),
        )?;
        Ok(Unauthenticated {
            clients,
            default_output,
            tenant: context.name().to_owned(),
            tsg: crate::tenant::config::js_trim(
                context
                    .config
                    .effective_str("mgmtTsgId")
                    .unwrap_or_default(),
            )
            .to_owned(),
        })
    }
}

/// Fetch the OAuth token of a write (the SDK's token budget); an interruption reports
/// `contexts.not_sent`.
pub async fn authenticate(prepared: &Unauthenticated, contexts: &Contexts) -> Result<(), CliError> {
    prepared
        .clients
        .authenticate(None, TOKEN_BUDGET)
        .await
        .map_err(|error| contexts.before(error))
}

/// The tenant, the client and the token of a write that asks nothing first.
pub async fn connect(
    invocation: &mut Invocation,
    contexts: &Contexts,
) -> Result<Unauthenticated, CliError> {
    let prepared = invocation
        .red_team_unauthenticated()
        .map_err(|error| contexts.before(error))?;
    authenticate(&prepared, contexts).await?;
    Ok(prepared)
}

/// `refusing to <action> without --force in non-interactive mode` (the D81 gate,
/// checked during planning: exit 2 before the tenant).
pub fn gate(force: bool, interactive: bool, action: &str) -> Result<(), CliError> {
    if force || interactive {
        Ok(())
    } else {
        Err(CliError::usage(format!(
            "refusing to {} without --force in non-interactive mode",
            terminal_safe(action)
        )))
    }
}

/// The D81 confirmation (`? <message> (y/N) ` on stderr); a decline prints
/// `  ℹ Aborted` and returns `false` (exit 0, nothing sent).
pub fn confirm(invocation: &mut Invocation, message: &str) -> Result<bool, CliError> {
    let message = terminal_safe(message).into_owned();
    if super::confirm(invocation, &message)? {
        Ok(true)
    } else {
        invocation.output.info("Aborted");
        Ok(false)
    }
}

/// One red team write (D133): one attempt with the single 401 replay; an unknown or
/// unreadable outcome gets its outcome line and `hint`; an interruption maps to
/// `contexts`.
pub async fn send<T>(
    invocation: &Invocation,
    change: Change,
    hint: Option<&str>,
    contexts: &Contexts,
    call: impl AsyncFnMut() -> scm::Result<T>,
) -> Result<T, CliError> {
    writes::send_hinted(&invocation.secrets, change, hint, call)
        .await
        .map_err(|error| match error {
            CliError::Interrupted(InterruptContext::NothingChanged) => {
                CliError::Interrupted(contexts.not_sent.clone())
            }
            CliError::Interrupted(InterruptContext::ChangeMayBeApplied) => {
                CliError::Interrupted(contexts.sent.clone())
            }
            other => other,
        })
}

/// A JSON input file of at most 1 MiB that must hold one object (`--config`, §15.3).
pub fn config_object(path: &OsStr) -> Result<serde_json::Value, CliError> {
    crate::files::read_config(path).map(serde_json::Value::Object)
}

/// A request body validated with its SDK schema (D116 text on failure, exit 1).
pub fn body<M>(
    value: serde_json::Value,
    method: &str,
    path: &str,
    parse: impl FnOnce(serde_json::Value, scm::validation::Mode) -> scm::Result<M>,
) -> Result<M, CliError> {
    parse(value, scm::validation::Mode::Request).map_err(|_| writes::invalid_body(method, path))
}

/// Register the credential values of a document the user supplied (§15.4: the same
/// rules as for printed documents), without changing it.
pub fn register_secrets(
    invocation: &Invocation,
    value: &serde_json::Value,
    shape: crate::redteam::secrets::Shape,
) {
    if let Ok(mut copy) = crate::json::to_ordered(value) {
        crate::redteam::secrets::redact(&mut copy, shape, &invocation.secrets);
    }
}

/// A string field of an answer (`""` when absent).
pub fn field(doc: &OValue, key: &str) -> String {
    doc.get(key)
        .and_then(OValue::as_str)
        .unwrap_or_default()
        .to_owned()
}