Skip to main content

vta_cli_common/commands/
external.rs

1//! `pnm external …` (and any CLI that adopts it) — external accounts: identities
2//! a VTA holds at clouds and third-party services for the integrations bound to
3//! them (`external/*/0.1`; design `docs/05-design-notes/vta-external-accounts.md`).
4//!
5//! Every request is built as the task's generated payload type, so a value the
6//! schema refuses is caught here, with the schema's own words, before anything
7//! is sent. Answers print as JSON: an account is a nested structure, and the
8//! console that renders it nicely is the VTC's.
9//!
10//! A secret never appears on a command line. `secret-set` reads it from the
11//! terminal without echo (or from stdin when piped), seals it in this process
12//! to a single-use wrapping key the VTA hands out, and sends only the armor.
13
14use std::io::{IsTerminal, Read};
15
16use serde::de::DeserializeOwned;
17use serde_json::{Value, json};
18use vta_sdk::prelude::*;
19use vti_specs::external as ext;
20
21use crate::render::print_json;
22
23type CliResult = Result<(), Box<dyn std::error::Error>>;
24
25/// Build a generated payload from JSON, refusing what its schema refuses.
26fn payload<P: DeserializeOwned>(task: &str, v: Value) -> Result<P, Box<dyn std::error::Error>> {
27    serde_json::from_value(v).map_err(|e| format!("{task}: the request is not valid: {e}").into())
28}
29
30/// Settings as JSON text, or `@path` to read them from a file.
31fn settings_arg(raw: &str) -> Result<Value, Box<dyn std::error::Error>> {
32    let text = match raw.strip_prefix('@') {
33        Some(path) => std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?,
34        None => raw.to_string(),
35    };
36    serde_json::from_str(&text).map_err(|e| format!("--settings is not JSON: {e}").into())
37}
38
39fn show<T: serde::Serialize>(v: &T) -> CliResult {
40    print_json(v)?;
41    Ok(())
42}
43
44/// `list`.
45pub async fn cmd_list(
46    client: &VtaClient,
47    context: &str,
48    state: Option<&str>,
49    model: Option<&str>,
50) -> CliResult {
51    let mut v = json!({ "context": context });
52    if let Some(s) = state {
53        v["state"] = json!(s);
54    }
55    if let Some(m) = model {
56        v["model"] = json!(m);
57    }
58    let req = payload::<ext::accounts::list::v0_2::Payload>("external/accounts/list", v)?;
59    show(&client.external_accounts_list(&req).await?)
60}
61
62/// `get`.
63pub async fn cmd_get(client: &VtaClient, context: &str, id: &str) -> CliResult {
64    let req = payload::<ext::accounts::get::v0_2::Payload>(
65        "external/accounts/get",
66        json!({ "context": context, "id": id }),
67    )?;
68    show(&client.external_accounts_get(&req).await?)
69}
70
71/// `create`.
72pub async fn cmd_create(
73    client: &VtaClient,
74    context: &str,
75    id: &str,
76    label: &str,
77    settings: &str,
78) -> CliResult {
79    let req = payload::<ext::accounts::create::v0_2::Payload>(
80        "external/accounts/create",
81        json!({ "context": context, "id": id, "label": label, "settings": settings_arg(settings)? }),
82    )?;
83    show(&client.external_accounts_create(&req).await?)
84}
85
86/// `update`.
87pub async fn cmd_update(
88    client: &VtaClient,
89    context: &str,
90    id: &str,
91    label: Option<&str>,
92    settings: Option<&str>,
93) -> CliResult {
94    let mut v = json!({ "context": context, "id": id });
95    if let Some(l) = label {
96        v["label"] = json!(l);
97    }
98    if let Some(s) = settings {
99        v["settings"] = settings_arg(s)?;
100    }
101    let req = payload::<ext::accounts::update::v0_2::Payload>("external/accounts/update", v)?;
102    show(&client.external_accounts_update(&req).await?)
103}
104
105/// `secret-set`: read the secret without echo, seal it here, send the armor.
106pub async fn cmd_secret_set(client: &VtaClient, context: &str, id: &str) -> CliResult {
107    // The account's access key id goes inside the seal, so the VTA refuses a
108    // secret that belongs to another key.
109    let account = client
110        .external_accounts_get(&payload(
111            "external/accounts/get",
112            json!({ "context": context, "id": id }),
113        )?)
114        .await?;
115    let access_key_id = serde_json::to_value(&account.account.settings)?
116        .get("accessKeyId")
117        .and_then(Value::as_str)
118        .map(str::to_string);
119    let secret = zeroize::Zeroizing::new(if std::io::stdin().is_terminal() {
120        dialoguer::Password::new()
121            .with_prompt(format!("Secret for {context}/{id}"))
122            .interact()?
123    } else {
124        let mut s = String::new();
125        std::io::stdin().read_to_string(&mut s)?;
126        s.trim_end_matches(['\r', '\n']).to_string()
127    });
128    if secret.is_empty() {
129        return Err("no secret was given".into());
130    }
131    let wrapping = client.get_wrapping_key().await?;
132    let armored = vta_sdk::client::seal_external_secret(
133        &wrapping.wrapping_key,
134        context,
135        id,
136        &secret,
137        access_key_id.as_deref(),
138    )
139    .await?;
140    drop(secret);
141    let req = payload::<ext::accounts::secret::set::v0_1::Payload>(
142        "external/accounts/secret/set",
143        json!({ "context": context, "id": id, "wrappingKeyId": wrapping.key_id, "sealedSecret": armored }),
144    )?;
145    show(&client.external_accounts_secret_set(&req).await?)
146}
147
148/// `bind`: grant (or replace) a consumer's binding.
149#[allow(clippy::too_many_arguments)]
150pub async fn cmd_bind(
151    client: &VtaClient,
152    context: &str,
153    id: &str,
154    consumer: &str,
155    prefixes: &[String],
156    actions: &[String],
157    max_ttl_seconds: u32,
158    rate_per_minute: u32,
159) -> CliResult {
160    let mut binding = json!({
161        "consumer": consumer,
162        "maxTtlSeconds": max_ttl_seconds,
163        "ratePerMinute": rate_per_minute,
164    });
165    if !prefixes.is_empty() || !actions.is_empty() {
166        binding["scopeCeiling"] = json!({ "prefixes": prefixes, "actions": actions });
167    }
168    let req = payload::<ext::accounts::bindings::grant::v0_2::Payload>(
169        "external/accounts/bindings/grant",
170        json!({ "context": context, "id": id, "binding": binding }),
171    )?;
172    show(&client.external_accounts_bindings_grant(&req).await?)
173}
174
175/// `unbind`.
176pub async fn cmd_unbind(client: &VtaClient, context: &str, id: &str, consumer: &str) -> CliResult {
177    let req = payload::<ext::accounts::bindings::revoke::v0_2::Payload>(
178        "external/accounts/bindings/revoke",
179        json!({ "context": context, "id": id, "consumer": consumer }),
180    )?;
181    show(&client.external_accounts_bindings_revoke(&req).await?)
182}
183
184/// `setup`: what to do at the provider, with each artifact's content.
185pub async fn cmd_setup(client: &VtaClient, context: &str, id: &str) -> CliResult {
186    let req = payload::<ext::accounts::setup::v0_1::Payload>(
187        "external/accounts/setup",
188        json!({ "context": context, "id": id }),
189    )?;
190    show(&client.external_accounts_setup(&req).await?)
191}
192
193/// `probe`.
194pub async fn cmd_probe(client: &VtaClient, context: &str, id: &str) -> CliResult {
195    let req = payload::<ext::accounts::probe::v0_2::Payload>(
196        "external/accounts/probe",
197        json!({ "context": context, "id": id }),
198    )?;
199    show(&client.external_accounts_probe(&req).await?)
200}
201
202/// The lifecycle verbs, which share one shape.
203#[derive(Debug, Clone, Copy)]
204pub enum Lifecycle {
205    Suspend,
206    Resume,
207    Archive,
208    Restore,
209    Delete,
210}
211
212/// `suspend` / `resume` / `archive` / `restore` / `delete`.
213pub async fn cmd_lifecycle(
214    client: &VtaClient,
215    verb: Lifecycle,
216    context: &str,
217    id: &str,
218    reason: Option<&str>,
219) -> CliResult {
220    let mut v = json!({ "context": context, "id": id });
221    if let Some(r) = reason {
222        v["reason"] = json!(r);
223    }
224    match verb {
225        Lifecycle::Suspend => show(
226            &client
227                .external_accounts_suspend(&payload("external/accounts/suspend", v)?)
228                .await?,
229        ),
230        Lifecycle::Resume => show(
231            &client
232                .external_accounts_resume(&payload("external/accounts/resume", v)?)
233                .await?,
234        ),
235        Lifecycle::Archive => show(
236            &client
237                .external_accounts_archive(&payload("external/accounts/archive", v)?)
238                .await?,
239        ),
240        Lifecycle::Restore => show(
241            &client
242                .external_accounts_restore(&payload("external/accounts/restore", v)?)
243                .await?,
244        ),
245        Lifecycle::Delete => show(
246            &client
247                .external_accounts_delete(&payload("external/accounts/delete", v)?)
248                .await?,
249        ),
250    }
251}
252
253/// `issue`: a credential for this CLI's own identity, as a bound consumer
254/// (`external/credentials/issue/0.3`, `scope`: every brokered model but
255/// `s3-static-presign`, which takes [`cmd_issue_presign`]).
256/// The answer is printed still sealed — it is for the consumer's process, and
257/// a terminal is not where a provider credential should be opened.
258pub async fn cmd_issue(
259    client: &VtaClient,
260    context: &str,
261    account: &str,
262    prefix: Option<&str>,
263    actions: &[String],
264    ttl_seconds: u32,
265) -> CliResult {
266    let mut scope = json!({});
267    if let Some(p) = prefix {
268        scope["prefix"] = json!(p);
269    }
270    if !actions.is_empty() {
271        scope["actions"] = json!(actions);
272    }
273    let req = payload::<ext::credentials::issue::v0_3::Payload>(
274        "external/credentials/issue",
275        json!({ "context": context, "account": account, "scope": scope, "ttlSeconds": ttl_seconds }),
276    )?;
277    show(&client.external_credentials_issue(&req).await?)
278}
279
280/// `issue --presign`: a batch of presigned requests on one object of an
281/// `s3-static-presign` account (`external/credentials/issue/0.3`). `batch` is
282/// the `PresignBatch` JSON, or `@path` to read it; at most 64 operations.
283/// Printed still sealed, like `issue`.
284///
285/// A batch whose sealed answer would not fit is refused `responseTooLarge`, at
286/// no cost to the binding's rate, and asked for again in parts (consumer item
287/// 5); the answers are then printed as a JSON array, one per part, in order.
288pub async fn cmd_issue_presign(
289    client: &VtaClient,
290    context: &str,
291    account: &str,
292    batch: &str,
293    ttl_seconds: u32,
294) -> CliResult {
295    let raw = match batch.strip_prefix('@') {
296        Some(path) => std::fs::read_to_string(path)
297            .map_err(|e| format!("cannot read the presign batch {path}: {e}"))?,
298        None => batch.to_string(),
299    };
300    let presign: serde_json::Value =
301        serde_json::from_str(&raw).map_err(|e| format!("the presign batch is not JSON: {e}"))?;
302    let req = payload::<ext::credentials::issue::v0_3::Payload>(
303        "external/credentials/issue",
304        json!({ "context": context, "account": account, "presign": presign, "ttlSeconds": ttl_seconds }),
305    )?;
306    let answers = client.external_credentials_issue_presign(&req).await?;
307    match answers.as_slice() {
308        [one] => show(one),
309        parts => {
310            eprintln!(
311                "the VTA could not seal this batch in one answer; it was issued in {} parts",
312                parts.len()
313            );
314            show(&parts)
315        }
316    }
317}