diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 612ad9c..3d0d930 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -23,6 +23,7 @@ mod dead_letters; mod email_queue; mod enabled; mod env_overlay; +mod group_secrets; mod groups; mod init; mod invoke; diff --git a/crates/picloud-cli/tests/fixtures/read-secret.rhai b/crates/picloud-cli/tests/fixtures/read-secret.rhai new file mode 100644 index 0000000..0b2db58 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/read-secret.rhai @@ -0,0 +1,5 @@ +// Phase-3 group-secrets journey fixture: returns the resolved `stripe-key` +// secret verbatim so the test can assert runtime injection across the group +// chain (inherited group value) vs an app-owned proximity override. The +// value is decrypted under the resolved owner's AAD before injection. +secrets::get("stripe-key") diff --git a/crates/picloud-cli/tests/group_secrets.rs b/crates/picloud-cli/tests/group_secrets.rs new file mode 100644 index 0000000..c651cbc --- /dev/null +++ b/crates/picloud-cli/tests/group_secrets.rs @@ -0,0 +1,187 @@ +//! Phase-3 group secrets, end to end via `pic`: +//! +//! 1. **Inheritance + proximity** — a group-owned secret is injected into a +//! descendant app's script via `secrets::get` (decrypted under the +//! group AAD), and an app-owned secret of the same name shadows it +//! (decrypted under the app AAD). Exercises the resolver + the dual +//! owner-AAD open path against real Postgres. +//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE, +//! an app-only dev is denied the value (403) yet still sees the secret +//! EXISTS (masked) in `config/effective`. That is the headline §5.3 +//! property: an app runs with config its own devs cannot read. + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard}; +use crate::common::member; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_secret_is_injected_then_app_value_overrides() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("gs-acme"); + let app = common::unique_slug("gs-app"); + + // Group `acme` with a `stripe-key` group secret (value via stdin). + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + common::pic_as(&env) + .args(["secrets", "set", "--group", &acme, "stripe-key"]) + .write_stdin("sk_group") + .assert() + .success(); + + // App under acme with a script that reads + returns the resolved secret. + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + let fixture = common::fixture_path("read-secret.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .success(); + let ls = common::pic_as(&env) + .args(["scripts", "ls", "--app", &app]) + .output() + .expect("scripts ls"); + let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap()) + .expect("scripts ls should produce one row"); + + // Inherited: the app has no own `stripe-key`, so the group's value is + // injected (decrypted under the GROUP AAD). + assert_eq!( + invoke_body(&env, &id), + serde_json::json!("sk_group"), + "inherited group secret" + ); + + // Proximity override: an app-owned `stripe-key` shadows the group value + // (decrypted under the APP AAD — proving both AAD namespaces open). + common::pic_as(&env) + .args(["secrets", "set", "--app", &app, "stripe-key"]) + .write_stdin("sk_app") + .assert() + .success(); + assert_eq!( + invoke_body(&env, &id), + serde_json::json!("sk_app"), + "app override" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_secret_value_is_masked_from_app_devs() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("gsm-acme"); + let app = common::unique_slug("gsm-app"); + + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + common::pic_as(&env) + .args(["secrets", "set", "--group", &acme, "stripe-key"]) + .write_stdin("sk_live_masked") + .assert() + .success(); + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + + // An app dev: a Member granted `editor` on the app, but NO group role. + let dev = member::member_user(fx, &common::unique_username("appdev")); + let dev_env = common::custom_env(&fx.url, &dev.token); + common::seed_credentials(&dev_env, &dev.username); + member::grant_membership(fx, &app, &dev.id, "editor"); + + // Denied the VALUE: the value endpoint is gated GroupSecretsRead at the + // owning group, which the app dev does not hold. + common::pic_as(&dev_env) + .args(["secrets", "read", "--group", &acme, "stripe-key"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 403")); + + // But the dev DOES see it EXISTS (masked) in the app's effective config — + // status `set`, owner group, never the value. Asserted directly against + // the endpoint to avoid the CLI's manifest requirement. + let effective = reqwest::blocking::Client::new() + .get(format!( + "{}/api/v1/admin/apps/{}/config/effective", + fx.url, app + )) + .bearer_auth(&dev.token) + .send() + .expect("config effective"); + assert!( + effective.status().is_success(), + "dev can read effective config" + ); + let body: serde_json::Value = effective.json().expect("effective json"); + let masked = &body["secrets"]["stripe-key"]; + assert_eq!(masked["status"], "set", "secret shown as set"); + assert_eq!(masked["owner"]["kind"], "group", "owned by the group"); + assert!( + masked.get("value").is_none(), + "value must never appear in effective config: {masked}" + ); + + // A group_admin CAN read the value. + let gadmin = member::member_user(fx, &common::unique_username("gadmin")); + let gadmin_env = common::custom_env(&fx.url, &gadmin.token); + common::seed_credentials(&gadmin_env, &gadmin.username); + common::pic_as(&env) + .args([ + "groups", + "members", + "add", + &acme, + &gadmin.id, + "--role", + "app_admin", + ]) + .assert() + .success(); + common::pic_as(&gadmin_env) + .args(["secrets", "read", "--group", &acme, "stripe-key"]) + .assert() + .success() + .stdout(predicate::str::contains("sk_live_masked")); +} + +/// Invoke a script via `pic scripts invoke ` (→ `/api/v1/execute/{id}`) +/// and parse its JSON body. +fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { + let out = common::pic_as(env) + .args(["scripts", "invoke", id]) + .output() + .expect("scripts invoke"); + assert!( + out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("invoke body is JSON") +} diff --git a/crates/picloud-cli/tests/secrets.rs b/crates/picloud-cli/tests/secrets.rs index 6e93470..157ce35 100644 --- a/crates/picloud-cli/tests/secrets.rs +++ b/crates/picloud-cli/tests/secrets.rs @@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() { .expect("secrets ls"); let stdout = String::from_utf8(out.stdout).unwrap(); let header = stdout.lines().next().expect("header"); - assert_eq!(common::cells(header), vec!["name", "updated_at"]); + assert_eq!(common::cells(header), vec!["name", "env", "updated_at"]); assert!( stdout.lines().skip(1).any(|l| l.starts_with("api_key")), "api_key missing from ls: {stdout}"