max / makenotwork
7 files changed,
+141 insertions,
-30 deletions
| @@ -66,8 +66,12 @@ pub struct Host { | |||
| 66 | 66 | fn default_actuate() -> Vec<String> { | |
| 67 | 67 | vec!["build".into(), "package".into()] | |
| 68 | 68 | } | |
| 69 | + | /// A build host's read-only surface: its build logs, and the artifacts it | |
| 70 | + | /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`). | |
| 71 | + | /// Retrieving the artifact is the last step of every release, so defaulting it on | |
| 72 | + | /// keeps an existing `bento.toml` from dead-ending there after a full build. | |
| 69 | 73 | fn default_observe() -> Vec<String> { | |
| 70 | - | vec!["build-log".into()] | |
| 74 | + | vec!["build-log".into(), "artifact".into()] | |
| 71 | 75 | } | |
| 72 | 76 | ||
| 73 | 77 | #[derive(Debug, Clone, Deserialize)] |
| @@ -80,12 +80,18 @@ async fn main() -> Result<()> { | |||
| 80 | 80 | // contacted and a step has already run on the build host. | |
| 81 | 81 | plan.validate().context("driver config")?; | |
| 82 | 82 | ||
| 83 | - | // The driver's caller-side grant: it may build/sign/notarize/staple and | |
| 84 | - | // observe the gatekeeper verdict. The agent re-checks against its own grant. | |
| 83 | + | // The driver's caller-side grant: it may build/sign/notarize/staple, observe | |
| 84 | + | // the gatekeeper verdict, and pull the artifact it just had built (`artifact` | |
| 85 | + | // — the last step of the flow below). The agent re-checks against its own | |
| 86 | + | // grant. Note `gatekeeper` is also implied by `sign` in `from_tokens`; it is | |
| 87 | + | // spelled out here because this list documents what the driver needs. | |
| 85 | 88 | let exec = AgentRpc::new( | |
| 86 | 89 | cfg.agent.base_url.clone(), | |
| 87 | 90 | cfg.agent.host_label.clone(), | |
| 88 | - | CapabilitySet::from_tokens(["build", "sign", "notarize", "staple"], ["gatekeeper"]), | |
| 91 | + | CapabilitySet::from_tokens( | |
| 92 | + | ["build", "sign", "notarize", "staple"], | |
| 93 | + | ["gatekeeper", "artifact"], | |
| 94 | + | ), | |
| 89 | 95 | ); | |
| 90 | 96 | ||
| 91 | 97 | // Fail fast if the agent isn't reachable / in-session. |
| @@ -15,19 +15,30 @@ listen = "100.64.0.2:8765" | |||
| 15 | 15 | # confined under this root, so keep it to the artifacts tree -- NOT $HOME, which | |
| 16 | 16 | # would expose ~/.tauri/passwords.env and the .p8 to any allow-listed caller. | |
| 17 | 17 | # Omit the key entirely to leave /pull disabled. | |
| 18 | + | # | |
| 19 | + | # pull_root sets WHERE /pull may read; the `artifact` observe grant below sets | |
| 20 | + | # WHO may read it. Both are required -- either one missing answers 403. | |
| 18 | 21 | pull_root = "/Users/max/Dist" | |
| 19 | 22 | ||
| 20 | 23 | # What THIS host is allowed to do — the ceiling for every caller. The Mac signs. | |
| 24 | + | # | |
| 25 | + | # observe kinds: `build-log` = read the build's log output; `artifact` = retrieve | |
| 26 | + | # a built release artifact via /pull, confined to pull_root. They are separate on | |
| 27 | + | # purpose -- a caller can be granted the DMG without the logs, or the reverse. | |
| 28 | + | # A host that grants `sign` additionally gets `gatekeeper` implicitly (a signer | |
| 29 | + | # must be able to verify its own signature, or every publish gate dead-ends), so | |
| 30 | + | # /health on this host reports observe = ["build-log", "artifact", "gatekeeper"] | |
| 31 | + | # even though only two are listed here. That is expected, not drift. | |
| 21 | 32 | [grant] | |
| 22 | 33 | actuate = ["build", "sign", "notarize", "staple", "package"] | |
| 23 | - | observe = ["build-log"] | |
| 34 | + | observe = ["build-log", "artifact"] | |
| 24 | 35 | ||
| 25 | 36 | # Callers this agent trusts. `identity` is a tailnet node name (e.g. `fw13`) or a | |
| 26 | 37 | # tag (e.g. `tag:builder`). The effective grant is this list ∩ [grant] above. | |
| 27 | 38 | [[allow]] | |
| 28 | 39 | identity = "fw13" # the Sando/Bento daemon host | |
| 29 | 40 | actuate = ["build", "sign", "notarize", "staple", "package"] | |
| 30 | - | observe = ["build-log"] | |
| 41 | + | observe = ["build-log", "artifact"] | |
| 31 | 42 | ||
| 32 | 43 | # OPTIONAL script pins (minimal "signed recipe" control). For a high-risk action | |
| 33 | 44 | # whose recipe is a fixed shell script, pin the exact script so an allow-listed |
| @@ -405,14 +405,15 @@ async fn pull( | |||
| 405 | 405 | use axum::http::StatusCode; | |
| 406 | 406 | use axum::response::IntoResponse; | |
| 407 | 407 | ||
| 408 | - | // Identity → authorization. Reading host files is an observe-plane action, | |
| 409 | - | // gated by the `build-log` observe grant. | |
| 408 | + | // Identity → authorization. Retrieving an artifact is an observe-plane | |
| 409 | + | // action with its own grant: `artifact` covers reads under `pull_root`, | |
| 410 | + | // which is a different thing to want than a build's log output. | |
| 410 | 411 | let (_identity, effective) = match resolve_caller(&state, peer.ip()).await { | |
| 411 | 412 | Ok(pair) => pair, | |
| 412 | 413 | Err(resp) => return resp, | |
| 413 | 414 | }; | |
| 414 | - | if !effective.permits_observe(&ObserveKind::BuildLog) { | |
| 415 | - | return (StatusCode::FORBIDDEN, "pull denied: requires `build-log` observe grant").into_response(); | |
| 415 | + | if !effective.permits_observe(&ObserveKind::Artifact) { | |
| 416 | + | return (StatusCode::FORBIDDEN, "pull denied: requires `artifact` observe grant").into_response(); | |
| 416 | 417 | } | |
| 417 | 418 | ||
| 418 | 419 | // Confine to the configured artifacts root. No root configured ⇒ disabled. | |
| @@ -516,21 +517,29 @@ mod tests { | |||
| 516 | 517 | } | |
| 517 | 518 | ||
| 518 | 519 | #[test] | |
| 519 | - | fn pull_requires_a_known_caller_with_build_log_observe() { | |
| 520 | + | fn pull_requires_a_known_caller_with_artifact_observe() { | |
| 520 | 521 | // An unknown caller has no effective grant at all. | |
| 521 | 522 | let stranger = CallerIdentity { node: "stranger".into(), tags: vec![] }; | |
| 522 | 523 | assert!(effective_grant(&cfg(), &stranger).is_none()); | |
| 523 | 524 | ||
| 524 | - | // A known caller granted only actuate (no build-log observe) cannot pull. | |
| 525 | + | // A known caller granted only actuate (no observe) cannot pull. | |
| 525 | 526 | let mut c = cfg(); | |
| 526 | 527 | c.allow[0].observe = vec![]; | |
| 527 | 528 | c.grant.observe = vec![]; | |
| 528 | 529 | let eff = effective_grant(&c, &fw13()).unwrap(); | |
| 529 | - | assert!(!eff.permits_observe(&ObserveKind::BuildLog), "no observe grant ⇒ pull denied"); | |
| 530 | + | assert!(!eff.permits_observe(&ObserveKind::Artifact), "no observe grant ⇒ pull denied"); | |
| 530 | 531 | ||
| 531 | - | // The default fixture grants build-log on both sides ⇒ pull permitted. | |
| 532 | + | // `build-log` alone does not open /pull — the two grants are independent. | |
| 532 | 533 | let eff = effective_grant(&cfg(), &fw13()).unwrap(); | |
| 533 | 534 | assert!(eff.permits_observe(&ObserveKind::BuildLog)); | |
| 535 | + | assert!(!eff.permits_observe(&ObserveKind::Artifact), "build-log ⇏ artifact"); | |
| 536 | + | ||
| 537 | + | // Granting `artifact` on both sides ⇒ pull permitted. | |
| 538 | + | let mut c = cfg(); | |
| 539 | + | c.allow[0].observe.push("artifact".into()); | |
| 540 | + | c.grant.observe.push("artifact".into()); | |
| 541 | + | let eff = effective_grant(&c, &fw13()).unwrap(); | |
| 542 | + | assert!(eff.permits_observe(&ObserveKind::Artifact)); | |
| 534 | 543 | } | |
| 535 | 544 | ||
| 536 | 545 | fn sign_release_script() -> &'static str { |
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | use crate::capability::{CapabilityDenied, CapabilitySet}; | |
| 13 | 13 | use crate::executor::{Executor, SyncOpts}; | |
| 14 | 14 | use crate::remote::{LogSink, RunOutput}; | |
| 15 | - | use crate::step::Step; | |
| 15 | + | use crate::step::{Action, ObserveKind, Step}; | |
| 16 | 16 | use crate::wire::{Frame, HealthResponse, RunRequest}; | |
| 17 | 17 | use anyhow::{Context, Result}; | |
| 18 | 18 | use async_trait::async_trait; | |
| @@ -136,15 +136,30 @@ impl Executor for AgentRpc { | |||
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | 138 | async fn pull(&self, remote: &Path, local: &Path, _opts: &SyncOpts) -> Result<()> { | |
| 139 | + | // Caller-side enforcement (half 1 of 2), same as `run_streaming`. `/pull` | |
| 140 | + | // is observe-plane: it needs the `artifact` grant. Failing fast here | |
| 141 | + | // matters more than elsewhere — a pull is the *last* step of a release, | |
| 142 | + | // so an ungranted caller would otherwise learn it after a full build and | |
| 143 | + | // an Apple notary round trip. | |
| 144 | + | let action = Action::Observe(ObserveKind::Artifact); | |
| 145 | + | if !self.caps.permits(&action) { | |
| 146 | + | return Err(CapabilityDenied::new(&self.host_label, &action).into()); | |
| 147 | + | } | |
| 148 | + | ||
| 139 | 149 | let resp = self | |
| 140 | 150 | .client | |
| 141 | 151 | .get(format!("{}/pull", self.base_url)) | |
| 142 | 152 | .query(&[("path", remote.to_string_lossy().as_ref())]) | |
| 143 | 153 | .send() | |
| 144 | 154 | .await | |
| 145 | - | .context("GET /pull")? | |
| 146 | - | .error_for_status() | |
| 147 | - | .context("agent /pull status")?; | |
| 155 | + | .context("GET /pull")?; | |
| 156 | + | // Surface the agent's reason (ungranted, /pull disabled, outside | |
| 157 | + | // pull_root); `error_for_status` alone would reduce it to a bare 403. | |
| 158 | + | if resp.status() == reqwest::StatusCode::FORBIDDEN { | |
| 159 | + | let body = resp.text().await.unwrap_or_default(); | |
| 160 | + | anyhow::bail!("agent denied /pull: {}", body.trim()); | |
| 161 | + | } | |
| 162 | + | let resp = resp.error_for_status().context("agent /pull status")?; | |
| 148 | 163 | // Stream the body straight to disk in chunks — a signed .app/.dmg can be | |
| 149 | 164 | // hundreds of MB, so never buffer the whole artifact in the daemon's heap | |
| 150 | 165 | // (the agent's /pull already streams; this is the matching client half). |
| @@ -80,6 +80,12 @@ pub enum ObserveKind { | |||
| 80 | 80 | Metrics, | |
| 81 | 81 | Health, | |
| 82 | 82 | BuildLog, | |
| 83 | + | /// Retrieval of a *release artifact* the host produced — the grant `GET | |
| 84 | + | /// /pull` requires. Distinct from [`ObserveKind::BuildLog`]: a caller that | |
| 85 | + | /// wants the signed DMG does not thereby want the build's log output, and a | |
| 86 | + | /// caller that wants logs should not thereby be able to read the artifacts | |
| 87 | + | /// tree. Scope is the agent's configured `pull_root`, not the whole host. | |
| 88 | + | Artifact, | |
| 83 | 89 | Custom(String), | |
| 84 | 90 | } | |
| 85 | 91 | ||
| @@ -90,6 +96,7 @@ impl ObserveKind { | |||
| 90 | 96 | ObserveKind::Metrics => "metrics".into(), | |
| 91 | 97 | ObserveKind::Health => "health".into(), | |
| 92 | 98 | ObserveKind::BuildLog => "build-log".into(), | |
| 99 | + | ObserveKind::Artifact => "artifact".into(), | |
| 93 | 100 | ObserveKind::Custom(s) => s.clone(), | |
| 94 | 101 | } | |
| 95 | 102 | } | |
| @@ -100,6 +107,7 @@ impl ObserveKind { | |||
| 100 | 107 | "metrics" => ObserveKind::Metrics, | |
| 101 | 108 | "health" => ObserveKind::Health, | |
| 102 | 109 | "build-log" | "build_log" => ObserveKind::BuildLog, | |
| 110 | + | "artifact" => ObserveKind::Artifact, | |
| 103 | 111 | other => ObserveKind::Custom(other.to_string()), | |
| 104 | 112 | } | |
| 105 | 113 | } |
| @@ -138,7 +138,12 @@ async fn caller_side_gate_rejects_before_round_trip() { | |||
| 138 | 138 | assert!(err.downcast_ref::<CapabilityDenied>().is_some(), "expected caller-side CapabilityDenied"); | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | - | /// An allow-listed caller with a `build-log` observe grant can pull a file that | |
| 141 | + | /// The caller-side grant a driver needs to pull an artifact. | |
| 142 | + | fn puller() -> CapabilitySet { | |
| 143 | + | CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"]) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// An allow-listed caller with an `artifact` observe grant can pull a file that | |
| 142 | 147 | /// lives under the configured `pull_root`. | |
| 143 | 148 | #[tokio::test] | |
| 144 | 149 | async fn agent_pull_serves_an_in_root_file() { | |
| @@ -150,22 +155,49 @@ async fn agent_pull_serves_an_in_root_file() { | |||
| 150 | 155 | ||
| 151 | 156 | let grant = GrantConfig { | |
| 152 | 157 | actuate: vec!["build".into()], | |
| 153 | - | observe: vec!["build-log".into()], | |
| 158 | + | observe: vec!["artifact".into()], | |
| 154 | 159 | }; | |
| 155 | 160 | let base = spawn_agent_with_pull( | |
| 156 | - | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }], | |
| 161 | + | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }], | |
| 157 | 162 | grant, | |
| 158 | 163 | Some(root.clone()), | |
| 159 | 164 | ) | |
| 160 | 165 | .await; | |
| 161 | - | let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default()); | |
| 166 | + | let rpc = AgentRpc::new(base, "mbp", puller()); | |
| 162 | 167 | let local = dir.path().join("pulled.dmg"); | |
| 163 | 168 | rpc.pull(&artifact, &local, &Default::default()).await.unwrap(); | |
| 164 | 169 | assert_eq!(tokio::fs::read(&local).await.unwrap(), b"DMGBYTES"); | |
| 165 | 170 | } | |
| 166 | 171 | ||
| 167 | - | /// A caller without a `build-log` observe grant is refused, even for an in-root | |
| 168 | - | /// file — pull is gated, not open. | |
| 172 | + | /// `artifact` and `build-log` are independent grants: a caller allowed to read | |
| 173 | + | /// build logs is NOT thereby allowed to retrieve artifacts. This is the split | |
| 174 | + | /// the old `build-log`-gated /pull could not express. | |
| 175 | + | #[tokio::test] | |
| 176 | + | async fn agent_pull_denied_with_only_build_log_observe() { | |
| 177 | + | let dir = tempfile::tempdir().unwrap(); | |
| 178 | + | let root = dir.path().join("dist"); | |
| 179 | + | tokio::fs::create_dir_all(&root).await.unwrap(); | |
| 180 | + | let artifact = root.join("a.bin"); | |
| 181 | + | tokio::fs::write(&artifact, b"x").await.unwrap(); | |
| 182 | + | ||
| 183 | + | let grant = GrantConfig { actuate: vec![], observe: vec!["build-log".into(), "artifact".into()] }; | |
| 184 | + | let base = spawn_agent_with_pull( | |
| 185 | + | // The caller may read logs, but was never granted artifacts. | |
| 186 | + | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }], | |
| 187 | + | grant, | |
| 188 | + | Some(root.clone()), | |
| 189 | + | ) | |
| 190 | + | .await; | |
| 191 | + | // Client-side grant is deliberately wide so the *agent* is what refuses. | |
| 192 | + | let rpc = AgentRpc::new(base, "mbp", puller()); | |
| 193 | + | let local = dir.path().join("out.bin"); | |
| 194 | + | let err = rpc.pull(&artifact, &local, &Default::default()).await.unwrap_err(); | |
| 195 | + | assert!(err.to_string().contains("artifact"), "denial names the grant: {err}"); | |
| 196 | + | assert!(!local.exists(), "denied pull must not write a file"); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// A caller without any observe grant is refused, even for an in-root file — | |
| 200 | + | /// pull is gated, not open. | |
| 169 | 201 | #[tokio::test] | |
| 170 | 202 | async fn agent_pull_denied_without_observe_grant() { | |
| 171 | 203 | let dir = tempfile::tempdir().unwrap(); | |
| @@ -180,14 +212,40 @@ async fn agent_pull_denied_without_observe_grant() { | |||
| 180 | 212 | Some(root.clone()), | |
| 181 | 213 | ) | |
| 182 | 214 | .await; | |
| 183 | - | let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default()); | |
| 215 | + | let rpc = AgentRpc::new(base, "mbp", puller()); | |
| 184 | 216 | let local = dir.path().join("out.bin"); | |
| 185 | - | // The agent returns 403; AgentRpc surfaces it as a non-2xx status error and | |
| 186 | - | // the file never transfers. | |
| 217 | + | // The agent returns 403; AgentRpc surfaces the reason and the file never | |
| 218 | + | // transfers. | |
| 187 | 219 | assert!(rpc.pull(&artifact, &local, &Default::default()).await.is_err()); | |
| 188 | 220 | assert!(!local.exists(), "denied pull must not write a file"); | |
| 189 | 221 | } | |
| 190 | 222 | ||
| 223 | + | /// The caller-side half of double enforcement: a driver that never declared | |
| 224 | + | /// `artifact` fails before any request leaves the process. This is the check | |
| 225 | + | /// that saves a full build + notary round trip on a misconfigured driver. | |
| 226 | + | #[tokio::test] | |
| 227 | + | async fn agent_pull_denied_caller_side_without_declaring_artifact() { | |
| 228 | + | let dir = tempfile::tempdir().unwrap(); | |
| 229 | + | let root = dir.path().join("dist"); | |
| 230 | + | tokio::fs::create_dir_all(&root).await.unwrap(); | |
| 231 | + | let artifact = root.join("a.bin"); | |
| 232 | + | tokio::fs::write(&artifact, b"x").await.unwrap(); | |
| 233 | + | ||
| 234 | + | let grant = GrantConfig { actuate: vec![], observe: vec!["artifact".into()] }; | |
| 235 | + | let base = spawn_agent_with_pull( | |
| 236 | + | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }], | |
| 237 | + | grant, | |
| 238 | + | Some(root.clone()), | |
| 239 | + | ) | |
| 240 | + | .await; | |
| 241 | + | // Agent would happily serve this; the caller's own set is what refuses. | |
| 242 | + | let rpc = AgentRpc::new(base, "mbp", CapabilitySet::from_tokens(["build"], ["build-log"])); | |
| 243 | + | let local = dir.path().join("out.bin"); | |
| 244 | + | let err = rpc.pull(&artifact, &local, &Default::default()).await.unwrap_err(); | |
| 245 | + | assert!(err.to_string().contains("observe:artifact"), "caller-side denial: {err}"); | |
| 246 | + | assert!(!local.exists(), "denied pull must not write a file"); | |
| 247 | + | } | |
| 248 | + | ||
| 191 | 249 | /// A path outside `pull_root` is refused even for an authorized caller — the | |
| 192 | 250 | /// confinement boundary holds against traversal. | |
| 193 | 251 | #[tokio::test] | |
| @@ -198,14 +256,14 @@ async fn agent_pull_denied_outside_root() { | |||
| 198 | 256 | let secret = dir.path().join("secret.key"); | |
| 199 | 257 | tokio::fs::write(&secret, b"topsecret").await.unwrap(); | |
| 200 | 258 | ||
| 201 | - | let grant = GrantConfig { actuate: vec![], observe: vec!["build-log".into()] }; | |
| 259 | + | let grant = GrantConfig { actuate: vec![], observe: vec!["artifact".into()] }; | |
| 202 | 260 | let base = spawn_agent_with_pull( | |
| 203 | - | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }], | |
| 261 | + | vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }], | |
| 204 | 262 | grant, | |
| 205 | 263 | Some(root.clone()), | |
| 206 | 264 | ) | |
| 207 | 265 | .await; | |
| 208 | - | let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default()); | |
| 266 | + | let rpc = AgentRpc::new(base, "mbp", puller()); | |
| 209 | 267 | let local = dir.path().join("out.key"); | |
| 210 | 268 | let err = rpc.pull(&secret, &local, &Default::default()).await.unwrap_err(); | |
| 211 | 269 | assert!(!local.exists(), "out-of-root pull must not write a file"); |