Skip to main content

zad_cli/cli/
service_onepass.rs

1//! 1Password's plug-in to the generic service lifecycle.
2//!
3//! Authentication is always a 1Password Service Account token —
4//! agent-first, admin-rotated. The token is stored in the OS keychain
5//! and exported as `OP_SERVICE_ACCOUNT_TOKEN` for every `op` child
6//! process. See `docs/services.md#adding-a-new-service` for the full
7//! recipe.
8
9use crate::cli::DialoguerExt;
10use async_trait::async_trait;
11use clap::Args;
12use dialoguer::{Password, theme::ColorfulTheme};
13
14use crate::cli::lifecycle::{
15    CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
16    resolve_scopes,
17};
18use zad::config::{OnePassServiceCfg, ProjectConfig};
19use zad::error::{Result, ZadError};
20use zad::secrets::{self, Scope};
21use zad::service::onepass::client::OnePassClient;
22
23/// 1pass understands exactly two zad-level scopes. `read` admits the
24/// read-oriented verbs (vaults/items/tags/get/read/inject); `write`
25/// admits `create`. Both are off by default — operators enable
26/// explicitly at `zad service create 1pass --scopes read,write` time.
27const DEFAULT_SCOPES: &[&str] = &["read"];
28const ALL_SCOPES: &[&str] = &["read", "write"];
29
30/// Link to the 1Password Service Accounts dashboard. Printed during
31/// interactive create so the operator can mint or rotate a token
32/// without leaving the terminal.
33const OP_SERVICE_ACCOUNTS_URL: &str =
34    "https://my.1password.com/developer-tools/infrastructure-secrets/serviceaccount";
35
36// ---------------------------------------------------------------------------
37// credential shape
38// ---------------------------------------------------------------------------
39
40pub struct OnePassSecrets {
41    pub service_account_token: String,
42}
43
44// ---------------------------------------------------------------------------
45// `zad service create 1pass` args
46// ---------------------------------------------------------------------------
47
48#[derive(Debug, Args)]
49pub struct CreateArgs {
50    #[command(flatten)]
51    pub base: CreateArgsBase,
52    #[command(flatten)]
53    pub scopes: ScopesArg,
54
55    /// 1Password sign-in address (e.g. `my.1password.com`,
56    /// `team.1password.eu`).
57    #[arg(long)]
58    pub account: Option<String>,
59
60    /// Service Account token. If omitted, zad reads `--token-env` or
61    /// prompts interactively (password-style echo).
62    #[arg(long, conflicts_with = "token_env")]
63    pub token: Option<String>,
64
65    /// Read the service-account token from this environment variable
66    /// instead of a flag or prompt.
67    #[arg(long, conflicts_with = "token")]
68    pub token_env: Option<String>,
69
70    /// Optional default vault for commands that omit `--vault`.
71    #[arg(long)]
72    pub default_vault: Option<String>,
73}
74
75impl CreateArgsLike for CreateArgs {
76    fn base(&self) -> &CreateArgsBase {
77        &self.base
78    }
79}
80
81// ---------------------------------------------------------------------------
82// the trait impl
83// ---------------------------------------------------------------------------
84
85pub struct OnePassLifecycle;
86
87#[async_trait]
88impl LifecycleService for OnePassLifecycle {
89    const NAME: &'static str = "1pass";
90    const DISPLAY: &'static str = "1Password";
91    type Cfg = OnePassServiceCfg;
92    type Secrets = OnePassSecrets;
93
94    fn enable_in_project(cfg: &mut ProjectConfig) {
95        cfg.enable_one_pass();
96    }
97
98    fn disable_in_project(cfg: &mut ProjectConfig) {
99        cfg.disable_one_pass();
100    }
101
102    async fn validate(cfg: &OnePassServiceCfg, creds: &mut OnePassSecrets) -> Result<String> {
103        let client = OnePassClient::new(creds.service_account_token.clone(), cfg.account.clone());
104        let me = client.whoami().await?;
105        // Prefer the sign-in URL (which names the account domain) when
106        // the service-account type is set — agents like to see which
107        // account they're tied to. Fall back to UUID when the CLI
108        // doesn't populate URL.
109        let id = if !me.url.is_empty() {
110            me.url
111        } else if !me.user_uuid.is_empty() {
112            me.user_uuid
113        } else {
114            "service-account".into()
115        };
116        Ok(id)
117    }
118
119    fn store_secrets(creds: &OnePassSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
120        let account = secrets::account(Self::NAME, "service-account", scope);
121        secrets::store(&account, &creds.service_account_token)?;
122        Ok(vec![SecretRef {
123            label: "token",
124            account,
125            present: true,
126        }])
127    }
128
129    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
130        let account = secrets::account(Self::NAME, "service-account", scope);
131        secrets::delete(&account)?;
132        Ok(vec![SecretRef {
133            label: "token",
134            account,
135            present: false,
136        }])
137    }
138
139    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
140        let account = secrets::account(Self::NAME, "service-account", scope);
141        let present = secrets::load(&account)?.is_some();
142        Ok(vec![SecretRef {
143            label: "token",
144            account,
145            present,
146        }])
147    }
148
149    fn load_secrets(scope: Scope<'_>) -> Result<Option<OnePassSecrets>> {
150        let account = secrets::account(Self::NAME, "service-account", scope);
151        let Some(token) = secrets::load(&account)? else {
152            return Ok(None);
153        };
154        Ok(Some(OnePassSecrets {
155            service_account_token: token,
156        }))
157    }
158
159    fn cfg_human(cfg: &OnePassServiceCfg) -> Vec<(&'static str, String)> {
160        let mut out = vec![("account", cfg.account.clone())];
161        if let Some(v) = &cfg.default_vault {
162            out.push(("vault", v.clone()));
163        }
164        out
165    }
166
167    fn cfg_json(cfg: &OnePassServiceCfg) -> serde_json::Value {
168        serde_json::json!({
169            "account": cfg.account,
170            "default_vault": cfg.default_vault,
171        })
172    }
173
174    fn scopes_of(cfg: &OnePassServiceCfg) -> &[String] {
175        &cfg.scopes
176    }
177
178    fn post_create_hint(_cfg: &OnePassServiceCfg) -> Option<String> {
179        Some(OP_SERVICE_ACCOUNTS_URL.to_string())
180    }
181}
182
183#[async_trait]
184impl CliLifecycle for OnePassLifecycle {
185    type CreateArgs = CreateArgs;
186
187    async fn resolve(
188        args: &CreateArgs,
189        non_interactive: bool,
190    ) -> Result<(OnePassServiceCfg, OnePassSecrets)> {
191        let scopes = resolve_scopes(
192            args.scopes.scopes.as_deref(),
193            DEFAULT_SCOPES,
194            ALL_SCOPES,
195            non_interactive,
196        )?;
197
198        let account = resolve_account(args.account.as_deref(), non_interactive)?;
199        let token = resolve_token(
200            args.token.as_deref(),
201            args.token_env.as_deref(),
202            non_interactive,
203        )?;
204
205        Ok((
206            OnePassServiceCfg {
207                account,
208                scopes,
209                default_vault: args.default_vault.clone(),
210            },
211            OnePassSecrets {
212                service_account_token: token,
213            },
214        ))
215    }
216}
217
218// ---------------------------------------------------------------------------
219// prompt helpers
220// ---------------------------------------------------------------------------
221
222fn theme() -> ColorfulTheme {
223    ColorfulTheme::default()
224}
225
226fn resolve_account(flag: Option<&str>, non_interactive: bool) -> Result<String> {
227    if let Some(v) = flag {
228        return Ok(v.trim().to_string());
229    }
230    if non_interactive {
231        return Err(ZadError::MissingRequired("--account"));
232    }
233    println!();
234    println!("1Password sign-in address (e.g. `my.1password.com`, `team.1password.eu`)");
235    let v: String = dialoguer::Input::with_theme(&theme())
236        .with_prompt("Sign-in address")
237        .interact_text()
238        .into_zad()?;
239    Ok(v.trim().to_string())
240}
241
242fn resolve_token(
243    flag: Option<&str>,
244    env_flag: Option<&str>,
245    non_interactive: bool,
246) -> Result<String> {
247    if let Some(env) = env_flag {
248        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
249    }
250    if let Some(v) = flag {
251        return Ok(v.to_string());
252    }
253    if non_interactive {
254        return Err(ZadError::MissingRequired("--token or --token-env"));
255    }
256    println!();
257    println!("Create a Service Account token:");
258    println!("  {OP_SERVICE_ACCOUNTS_URL}");
259    println!("Grant it the vaults / permissions this agent needs, then paste the");
260    println!("`ops_…` token below.");
261    let v = Password::with_theme(&theme())
262        .with_prompt("Service Account token")
263        .interact()
264        .into_zad()?;
265    Ok(v)
266}