feat(cli): pic secrets --group, value read, and effective vars/config

Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 22:00:32 +02:00
parent c914758c09
commit 30441549d5
5 changed files with 347 additions and 70 deletions

View File

@@ -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<SecretListDto> {
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<SecretListDto> {
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<SecretValueDto> {
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<EffectiveConfigDto> {
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<Utc>,
}
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<MergedFromDto>,
}
#[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<String, EffectiveVarDto>,
#[serde(default)]
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
}
/// 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).