test(secrets): group-secret inheritance + masked-read journeys
Two end-to-end journeys via `pic` against a real server: * `group_secret_is_injected_then_app_value_overrides` — a group-owned secret is injected into a descendant app's script through `secrets::get` (decrypted under the group AAD), then an app-owned secret of the same name shadows it (decrypted under the app AAD). Drives the resolver + both owner-AAD open paths against Postgres. * `group_secret_value_is_masked_from_app_devs` — the §5.3 boundary: an app dev (editor on the app, no group role) is denied the secret VALUE (403) yet still sees it EXISTS, masked, in `config/effective` (status set, owner group, no value); a group_admin reads the value. Proves an app runs with config its own devs cannot read. Also updates the existing app-secrets `ls` journey for the new `env` column (group secrets are env-scoped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ mod dead_letters;
|
|||||||
mod email_queue;
|
mod email_queue;
|
||||||
mod enabled;
|
mod enabled;
|
||||||
mod env_overlay;
|
mod env_overlay;
|
||||||
|
mod group_secrets;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod init;
|
mod init;
|
||||||
mod invoke;
|
mod invoke;
|
||||||
|
|||||||
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
@@ -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")
|
||||||
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
@@ -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 <id>` (→ `/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")
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() {
|
|||||||
.expect("secrets ls");
|
.expect("secrets ls");
|
||||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
let header = stdout.lines().next().expect("header");
|
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!(
|
assert!(
|
||||||
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
||||||
"api_key missing from ls: {stdout}"
|
"api_key missing from ls: {stdout}"
|
||||||
|
|||||||
Reference in New Issue
Block a user