zad_cli/cli/
service_onepass.rs1use 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
23const DEFAULT_SCOPES: &[&str] = &["read"];
28const ALL_SCOPES: &[&str] = &["read", "write"];
29
30const OP_SERVICE_ACCOUNTS_URL: &str =
34 "https://my.1password.com/developer-tools/infrastructure-secrets/serviceaccount";
35
36pub struct OnePassSecrets {
41 pub service_account_token: String,
42}
43
44#[derive(Debug, Args)]
49pub struct CreateArgs {
50 #[command(flatten)]
51 pub base: CreateArgsBase,
52 #[command(flatten)]
53 pub scopes: ScopesArg,
54
55 #[arg(long)]
58 pub account: Option<String>,
59
60 #[arg(long, conflicts_with = "token_env")]
63 pub token: Option<String>,
64
65 #[arg(long, conflicts_with = "token")]
68 pub token_env: Option<String>,
69
70 #[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
81pub 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 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
218fn 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}