//! 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") }