diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 7a366de..3652765 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -657,41 +657,77 @@ impl Client { decode_status(resp).await } - /// `GET /api/v1/admin/apps/{id}/secrets` - pub async fn secrets_list(&self, app: &str) -> Result { - let app = seg(app); - let resp = self - .request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets")) - .send() - .await?; + /// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names + + /// last-modified for the owner. Values never travel on this path. `env` + /// is only meaningful for group owners (app secrets are env-agnostic). + pub async fn secrets_list( + &self, + owner: VarOwnerArg<'_>, + env: Option<&str>, + ) -> Result { + let mut path = format!("{}/secrets", owner.base_path()); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::GET, &path).send().await?; decode(resp).await } - /// `POST /api/v1/admin/apps/{id}/secrets` - pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> { - // `name` travels in the JSON body, not the path — only `app` needs encoding. - let app = seg(app); + /// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the + /// body and is only honored by group owners. + pub async fn secrets_set( + &self, + owner: VarOwnerArg<'_>, + name: &str, + value: serde_json::Value, + env: Option<&str>, + ) -> Result<()> { + // `name` travels in the JSON body, not the path. + let mut body = serde_json::json!({ "name": name, "value": value }); + if let Some(env) = env { + body["env"] = serde_json::Value::String(env.to_string()); + } let resp = self - .request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets")) - .json(&serde_json::json!({ "name": name, "value": value })) + .request(Method::POST, &format!("{}/secrets", owner.base_path())) + .json(&body) .send() .await?; decode_status(resp).await } - /// `DELETE /api/v1/admin/apps/{id}/secrets/{name}` - pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> { - let (app, name) = (seg(app), seg(name)); - let resp = self - .request( - Method::DELETE, - &format!("/api/v1/admin/apps/{app}/secrets/{name}"), - ) - .send() - .await?; + /// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}` + pub async fn secrets_delete( + &self, + owner: VarOwnerArg<'_>, + name: &str, + env: Option<&str>, + ) -> Result<()> { + let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name)); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::DELETE, &path).send().await?; decode_status(resp).await } + /// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path + /// that returns a decrypted secret value. Gated server-side at the owning + /// group; there is no app-secret equivalent by design. + pub async fn group_secret_read_value( + &self, + group: &str, + name: &str, + env: Option<&str>, + ) -> Result { + let (group, name) = (seg(group), seg(name)); + let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value"); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::GET, &path).send().await?; + decode(resp).await + } + // ---------- vars (Phase 3 config) ---------- /// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars @@ -740,6 +776,21 @@ impl Client { decode_status(resp).await } + /// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved + /// (group-inherited) vars plus masked secret statuses, each annotated with + /// the owner that won and the layers it merged from (§4.6). + pub async fn config_effective(&self, app: &str) -> Result { + let app = seg(app); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/config/effective"), + ) + .send() + .await?; + decode(resp).await + } + // ---------- domains ---------- /// `GET /api/v1/admin/apps/{id_or_slug}/domains` @@ -1484,9 +1535,72 @@ pub struct SecretListDto { #[derive(Debug, Deserialize)] pub struct SecretItemDto { pub name: String, + /// Env scope. Only meaningful (and populated) for group owners; the server + /// omits it for app secrets, so default to `*` when absent. + #[serde(default = "default_env")] + pub env: String, pub updated_at: DateTime, } +fn default_env() -> String { + "*".to_string() +} + +/// Plaintext value of a single group secret — the response of the gated +/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON. +#[derive(Debug, Deserialize)] +pub struct SecretValueDto { + #[allow(dead_code)] + pub name: String, + #[allow(dead_code)] + pub env: String, + pub value: serde_json::Value, +} + +// --- effective config (`/config/effective`) --- + +/// The owner (app or group) a resolved layer belongs to, with its distance +/// from the app in the inheritance chain (`depth` 0 = the app itself). +#[derive(Debug, Deserialize)] +pub struct EffectiveOwnerDto { + pub kind: String, + #[allow(dead_code)] + pub id: String, + pub depth: u32, +} + +/// One layer a resolved var merged from, deepest-first as the server returns it. +#[derive(Debug, Deserialize)] +pub struct MergedFromDto { + pub depth: u32, + pub scope: String, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveVarDto { + pub value: serde_json::Value, + pub owner: EffectiveOwnerDto, + pub scope: String, + #[serde(default)] + pub merged_from: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveSecretDto { + #[allow(dead_code)] + pub status: String, + pub owner: EffectiveOwnerDto, + pub scope: String, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveConfigDto { + #[serde(default)] + pub vars: std::collections::BTreeMap, + #[serde(default)] + pub secrets: std::collections::BTreeMap, +} + /// Per-script runtime config the CLI can now set (G3). All optional — an /// unset field is omitted so the server applies its own default (and the /// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides). diff --git a/crates/picloud-cli/src/cmds/config.rs b/crates/picloud-cli/src/cmds/config.rs index b8da11f..d92cc36 100644 --- a/crates/picloud-cli/src/cmds/config.rs +++ b/crates/picloud-cli/src/cmds/config.rs @@ -1,26 +1,38 @@ //! `pic config --effective` — read-only view of an app's resolved //! configuration, with secret values masked (§4.6). //! -//! Single-app today: "config" means the app's secrets, cross-referenced -//! against the manifest so an operator can see at a glance which declared -//! secrets are still unset and which live secrets aren't declared. Values are -//! never fetched or shown — only `` / ``. The command is shaped to -//! grow an `--explain` mode and inherited `vars` once groups/vars land (the -//! Phase-3 multi-level resolution). +//! Two sections: +//! * `vars` — the resolved (group-inherited) config vars, each `key = value` +//! annotated with the owner that won (kind + depth) and its scope. Pass +//! `--explain` to also dump each var's `merged_from` provenance — the +//! ordered (depth, scope) layers that fed the resolution. +//! * `secrets` — masked statuses, cross-referenced against the manifest so an +//! operator can see which declared secrets are still unset and which live +//! secrets aren't declared. Values are never fetched or shown here; the +//! server reports only `` / `` plus the owning layer. +//! +//! The vars + masked-secret owner info comes from the `/config/effective` +//! endpoint; the manifest is only used to flag `declared`/`unset` drift. use std::collections::BTreeSet; use std::path::Path; use anyhow::{bail, Result}; -use crate::client::Client; +use crate::client::{Client, EffectiveOwnerDto}; use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; +/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`). +fn owner_label(owner: &EffectiveOwnerDto) -> String { + format!("{}@{}", owner.kind, owner.depth) +} + pub async fn run( manifest_path: &Path, effective: bool, + explain: bool, env: Option<&str>, mode: OutputMode, ) -> Result<()> { @@ -34,24 +46,54 @@ pub async fn run( // base slug — when an overlay re-points slug/secrets. let manifest = Manifest::load_with_env(manifest_path, env)?; - let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); - let on_server: BTreeSet = client - .secrets_list(&manifest.app.slug) - .await? - .secrets - .into_iter() - .map(|s| s.name) - .collect(); + let eff = client.config_effective(&manifest.app.slug).await?; - let mut table = Table::new(["secret", "value", "status"]); + // --- vars: the resolved view, with winning owner + provenance. --- + let mut vars_table = Table::new(["key", "value", "owner", "scope"]); + for (key, var) in &eff.vars { + vars_table.row([ + key.clone(), + var.value.to_string(), + owner_label(&var.owner), + var.scope.clone(), + ]); + } + vars_table.print(mode); + + if explain { + // Provenance: the (depth, scope) layers each resolved key merged from. + let mut prov = Table::new(["key", "depth", "scope"]); + for (key, var) in &eff.vars { + for layer in &var.merged_from { + prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]); + } + } + prov.print(mode); + } + + // --- secrets: masked status, folding manifest drift + owning layer. --- + let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); + let on_server: BTreeSet = eff.secrets.keys().cloned().collect(); + + let mut table = Table::new(["secret", "value", "status", "owner", "scope"]); for name in declared.union(&on_server) { let (value, status) = match (declared.contains(name), on_server.contains(name)) { (true, true) => ("", "managed"), - (true, false) => ("", "declared, not pushed — `pic secret set`"), + (true, false) => ("", "declared, not pushed — `pic secrets set`"), (false, true) => ("", "on server, not in manifest"), (false, false) => unreachable!("name came from one of the two sets"), }; - table.row([name.clone(), value.to_string(), status.to_string()]); + let (owner, scope) = match eff.secrets.get(name) { + Some(s) => (owner_label(&s.owner), s.scope.clone()), + None => ("-".to_string(), "-".to_string()), + }; + table.row([ + name.clone(), + value.to_string(), + status.to_string(), + owner, + scope, + ]); } table.print(mode); Ok(()) diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 7dd1b11..ec67535 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId}; use serde::Deserialize; -use crate::client::Client; +use crate::client::{Client, VarOwnerArg}; use crate::config; use crate::manifest::{ CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp, @@ -45,7 +45,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> let app = client.apps_get(app_ident).await?; let scripts = client.scripts_list_by_app(app_ident).await?; let triggers = client.triggers_list(app_ident).await?.triggers; - let secrets = client.secrets_list(app_ident).await?.secrets; + let secrets = client + .secrets_list(VarOwnerArg::App(app_ident), None) + .await? + .secrets; let name_by_id: HashMap = scripts.iter().map(|s| (s.id, s.name.clone())).collect(); diff --git a/crates/picloud-cli/src/cmds/secrets.rs b/crates/picloud-cli/src/cmds/secrets.rs index 57b099d..b6129b2 100644 --- a/crates/picloud-cli/src/cmds/secrets.rs +++ b/crates/picloud-cli/src/cmds/secrets.rs @@ -1,31 +1,56 @@ -//! `pic secrets` subcommands: `ls`, `set`, `rm`. +//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets. //! -//! Set reads the secret value from stdin (the only safe channel — -//! inline values would leak into shell history). The value is sent -//! as a JSON string; pass `--json` to interpret stdin as raw JSON -//! (numbers, maps, …) instead. +//! Exactly one of `--group` / `--app` selects the owner (mirroring +//! `pic vars`). Set reads the secret value from stdin (the only safe +//! channel — inline values would leak into shell history). The value is +//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON +//! (numbers, maps, …) instead. `--env` is only meaningful for group +//! owners (app secrets are env-agnostic). use std::io::Read; use anyhow::{anyhow, Context, Result}; -use crate::client::Client; +use crate::client::{Client, VarOwnerArg}; use crate::config; use crate::output::{OutputMode, Table}; -pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { +/// Resolve the `--group`/`--app` pair into exactly one owner. +fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result> { + match (group, app) { + (Some(g), None) => Ok(VarOwnerArg::Group(g)), + (None, Some(a)) => Ok(VarOwnerArg::App(a)), + (Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")), + (None, None) => Err(anyhow!("pass one of --group / --app")), + } +} + +pub async fn ls( + group: Option<&str>, + app: Option<&str>, + env: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let resp = client.secrets_list(app).await?; - let mut table = Table::new(["name", "updated_at"]); + let resp = client.secrets_list(owner, env).await?; + let mut table = Table::new(["name", "env", "updated_at"]); for s in resp.secrets { - table.row([s.name, s.updated_at.to_rfc3339()]); + table.row([s.name, s.env, s.updated_at.to_rfc3339()]); } table.print(mode); Ok(()) } -pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> { +pub async fn set( + group: Option<&str>, + app: Option<&str>, + name: &str, + env: Option<&str>, + as_json: bool, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let mut buf = String::new(); @@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> { } else { serde_json::Value::String(trimmed.to_string()) }; - client.secrets_set(app, name, value).await?; + client.secrets_set(owner, name, value, env).await?; println!("Set secret {name}"); Ok(()) } -pub async fn rm(app: &str, name: &str) -> Result<()> { +pub async fn rm( + group: Option<&str>, + app: Option<&str>, + name: &str, + env: Option<&str>, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - client.secrets_delete(app, name).await?; + client.secrets_delete(owner, name, env).await?; println!("Deleted secret {name}"); Ok(()) } + +/// `pic secrets read --group ` — fetch and print a secret's +/// PLAINTEXT value. This is the ONLY command that reveals a secret value, +/// and it is gated server-side at the owning group (there is no app-secret +/// equivalent). String values print raw; JSON values print pretty. +pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.group_secret_read_value(group, name, env).await?; + match resp.value { + serde_json::Value::String(s) => println!("{s}"), + other => println!("{}", serde_json::to_string_pretty(&other)?), + } + Ok(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 5dc45b3..35a8076 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -267,6 +267,10 @@ struct ConfigArgs { /// Show the effective (resolved) config with secrets masked. #[arg(long)] effective: bool, + /// With `--effective`, also print each resolved var's `merged_from` + /// provenance — the ordered (depth, scope) layers it merged from. + #[arg(long)] + explain: bool, /// Resolve against the `picloud..toml` overlay (per-env slug + /// secrets), matching `pic plan --env` / `pic apply --env`. #[arg(long)] @@ -1142,31 +1146,67 @@ enum DeadLettersCmd { #[derive(Subcommand)] enum SecretsCmd { - /// List secret names + last-modified for an app. Values never - /// leave the server. + /// List secret names + last-modified for the owner. Values never + /// leave the server. `--env` filters group secrets (app secrets are + /// env-agnostic; the `env` column shows the scope for groups). Ls { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, }, /// Set a secret. Reads the value from stdin (the only safe /// channel — inline values would land in shell history). Pipe /// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`. + /// For group owners, `--env` scopes the secret. Set { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, name: String, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, /// Treat stdin as raw JSON (numbers, maps, …) instead of a /// string literal. #[arg(long)] json: bool, }, - /// Delete a secret by name. + /// Delete a secret by name (optionally env-scoped for groups). Rm { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, name: String, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, + }, + + /// Fetch and print a secret's PLAINTEXT value. This is the ONLY + /// command that reveals a secret value, and it is gated server-side + /// at the owning group — hence `--group` only, no `--app`. + Read { + /// Owning group (slug or id). + #[arg(long)] + group: String, + name: String, + /// Environment scope. + #[arg(long)] + env: Option, }, } @@ -1293,7 +1333,14 @@ async fn main() -> ExitCode { Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await, Cmd::Config(args) => { - cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await + cmds::config::run( + &args.file, + args.effective, + args.explain, + args.env.as_deref(), + mode, + ) + .await } Cmd::Init(args) => cmds::init::run( &args.dir, @@ -1796,14 +1843,39 @@ async fn main() -> ExitCode { cmd: DeadLettersCmd::Resolve { app, dl_id, reason }, } => cmds::dead_letters::resolve(&app, &dl_id, &reason).await, Cmd::Secrets { - cmd: SecretsCmd::Ls { app }, - } => cmds::secrets::ls(&app, mode).await, + cmd: SecretsCmd::Ls { group, app, env }, + } => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await, Cmd::Secrets { - cmd: SecretsCmd::Set { app, name, json }, - } => cmds::secrets::set(&app, &name, json).await, + cmd: + SecretsCmd::Set { + group, + app, + name, + env, + json, + }, + } => { + cmds::secrets::set( + group.as_deref(), + app.as_deref(), + &name, + env.as_deref(), + json, + ) + .await + } Cmd::Secrets { - cmd: SecretsCmd::Rm { app, name }, - } => cmds::secrets::rm(&app, &name).await, + cmd: + SecretsCmd::Rm { + group, + app, + name, + env, + }, + } => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await, + Cmd::Secrets { + cmd: SecretsCmd::Read { group, name, env }, + } => cmds::secrets::read(&group, &name, env.as_deref()).await, Cmd::Members { cmd: MembersCmd::Ls { app }, } => cmds::members::ls(&app, mode).await,