Terraform state: reading one needs the write role bb018142cca7
Every account has the Read role on public and internal repositories, so any account's repo:read token read their Terraform state, which often holds secrets. Reading a state now needs a repo:write token and the write role, as changing one does; the repository's jobs keep their access. The Terraform state page lists the states only to writers. No state was kept on oxygenhub.io while this was open (checked by Andy). Closes #256
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b98a79ce581e
- When
- · 2026-10-18 10:50 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
bb018142cca7d18bd4f910c992653e74af783e8d2c7e80a98f9e5685553a7c63- Depends on
- 16ee5d19d220 42fa95540468 8dcaeeb848c6 93be9ce4653e
- Depended on by
- 67523e06ed86
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 12 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/terraform.rs +10 −14
| … | 79 unchanged lines |
||
| 80 | 80 | ||
| 81 | 81 | async fn who(app: &Shared, headers: &HeaderMap, row: &db::repos::RepoRow) -> Result<Who, ApiError> { |
|
| 82 | 82 | let token = token_of(headers).ok_or_else(|| { |
|
| 83 | - | err( |
|
| 84 | - | StatusCode::UNAUTHORIZED, |
|
| 85 | - | "a Terraform state needs a token: a job's (TF_HTTP_PASSWORD), or one of yours with repo:read or repo:write", |
|
| 86 | - | ) |
|
| 83 | + | err(StatusCode::UNAUTHORIZED, "a Terraform state needs a token: a job's (TF_HTTP_PASSWORD), or one of yours with repo:write") |
|
| 87 | 84 | })?; |
|
| 88 | 85 | let hash = crate::auth::token_hash(&token); |
|
| 89 | 86 | if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
|
| … | 28 unchanged lines |
||
| 118 | 115 | } |
|
| 119 | 116 | return Ok(Who::Job { name: sub.to_string() }); |
|
| 120 | 117 | }; |
|
| 121 | - | if !(token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")) { |
|
| 122 | - | return Err(err(StatusCode::FORBIDDEN, "the token lacks repo:read (to read a state) or repo:write (to change one)")); |
|
| 118 | + | if !(token.has_scope("repo:write") || token.has_scope("repo:admin")) { |
|
| 119 | + | return Err(err( |
|
| 120 | + | StatusCode::FORBIDDEN, |
|
| 121 | + | "the token lacks repo:write: a Terraform state, read or written, needs it (states hold secrets)", |
|
| 122 | + | )); |
|
| 123 | 123 | } |
|
| 124 | 124 | let slug = row.slug(); |
|
| 125 | 125 | if !token.covers(&slug) { |
|
| … | 7 unchanged lines |
||
| 133 | 133 | let role = crate::auth::roles::role_for(&app.db, Some(&account), row) |
|
| 134 | 134 | .await |
|
| 135 | 135 | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 136 | - | // A read-only token reads, whatever the role. |
|
| 137 | - | let role = match role { |
|
| 138 | - | Some(r) if !(token.has_scope("repo:write") || token.has_scope("repo:admin")) => Some(r.min(Role::Read)), |
|
| 139 | - | r => r, |
|
| 140 | - | }; |
|
| 141 | 136 | match role { |
|
| 142 | 137 | Some(r) if crate::auth::roles::allows(Some(r), Access::Read) => Ok(Who::Person { account: Box::new(account), role: r }), |
|
| 143 | 138 | _ => Err(err(StatusCode::NOT_FOUND, "no such repository")), |
|
| … | 32 unchanged lines |
||
| 176 | 171 | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 177 | 172 | .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?; |
|
| 178 | 173 | let who = who(&app, &headers, &row).await?; |
|
| 179 | - | let write = method != Method::GET; |
|
| 180 | - | if !who.may(if write { Access::Write } else { Access::Read }) { |
|
| 181 | - | return Err(err(StatusCode::FORBIDDEN, "changing a state needs the write role")); |
|
| 174 | + | // Reading a state needs the write role too: states hold secrets, and |
|
| 175 | + | // every account reads a public or internal repository (#256). |
|
| 176 | + | if !who.may(Access::Write) { |
|
| 177 | + | return Err(err(StatusCode::FORBIDDEN, "a Terraform state needs the write role on its repository: states hold secrets")); |
|
| 182 | 178 | } |
|
| 183 | 179 | let db_err = |e: sqlx::Error| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); |
|
| 184 | 180 | let current = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?; |
|
| … | 67 unchanged lines |
modified crates/hub/tests/flow.rs +27 −1
| … | 5225 unchanged lines |
||
| 5226 | 5226 | send("GET", "/api/v1/repos/andy/infra/terraform/state/..x".into(), Some(basic.clone()), "").await.0, |
|
| 5227 | 5227 | StatusCode::BAD_REQUEST |
|
| 5228 | 5228 | ); |
|
| 5229 | + | // A stranger reads a public repository, but not its state: states hold |
|
| 5230 | + | // secrets (#256), so reading one needs the write role, whatever the token. |
|
| 5231 | + | let mallory = db::accounts::create_account(&h.app.db, "mallory", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 5232 | + | let (secret, hash) = oxygenhub::auth::new_token(); |
|
| 5233 | + | db::accounts::create_token(&h.app.db, mallory.id, "cli", &hash, "repo:write", "", None).await.unwrap(); |
|
| 5234 | + | let (st, got) = send("GET", path.into(), Some(format!("Bearer {secret}")), "").await; |
|
| 5235 | + | assert_eq!(st, StatusCode::FORBIDDEN, "{got}"); |
|
| 5236 | + | assert!(!got.contains("hunter2")); |
|
| 5237 | + | let (_, page) = call(&h.app, "GET", "/andy/infra/terraform", Some(&secret), None).await; |
|
| 5238 | + | assert!(!page.contains("<strong>prod</strong>") && page.contains("shown to people who can write"), "{page}"); |
|
| 5229 | 5239 | // The site token is no person's: refused. |
|
| 5230 | 5240 | let (st, _) = send("GET", path.into(), Some("Bearer site".into()), "").await; |
|
| 5231 | 5241 | assert_eq!(st, StatusCode::UNAUTHORIZED, "the site token is not a person's token"); |
|
| … | 10 unchanged lines |
||
| 5242 | 5252 | let elsewhere = issuer.mint_for(h.app.config.base_url(), "repo:andy/infra:bookmark:main", "sts.amazonaws.com", now, 600); |
|
| 5243 | 5253 | assert_eq!(send("GET", path.into(), Some(format!("Bearer {elsewhere}")), "").await.0, StatusCode::UNAUTHORIZED); |
|
| 5244 | 5254 | // Its page under Artifacts (#254) lists the state and the backend block. |
|
| 5245 | - | let (st, page) = call(&h.app, "GET", "/andy/infra/terraform", None, None).await; |
|
| 5255 | + | // Its owner, signed in, sees the states. |
|
| 5256 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 5257 | + | .oneshot( |
|
| 5258 | + | Request::post("/register") |
|
| 5259 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 5260 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 5261 | + | .unwrap(), |
|
| 5262 | + | ) |
|
| 5263 | + | .await |
|
| 5264 | + | .unwrap(); |
|
| 5265 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 5266 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 5267 | + | .oneshot(Request::get("/andy/infra/terraform").header(header::COOKIE, cookie).body(Body::empty()).unwrap()) |
|
| 5268 | + | .await |
|
| 5269 | + | .unwrap(); |
|
| 5270 | + | let st = res.status(); |
|
| 5271 | + | let page = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 5246 | 5272 | assert_eq!(st, StatusCode::OK, "{page}"); |
|
| 5247 | 5273 | assert!(page.contains("<strong>prod</strong>") && page.contains("/api/v1/repos/andy/infra/terraform/state/NAME"), "{page}"); |
|
| 5248 | 5274 | assert!(page.contains("allow "") || page.contains("allow \""), "the egress line names the hub"); |
|
| … | 239 unchanged lines |
modified docs/spec.md +1 −1
| … | 632 unchanged lines |
||
| 633 | 633 | ||
| 634 | 634 | **Who may use it:** |
|
| 635 | 635 | - a job, with an identity token of this hub's for the audience `{base_url}/terraform` (its pipeline's `identity` block) or with its runner's job token, for its own repository only; |
|
| 636 | - | - a person, with a token of theirs (`repo:read` to read, `repo:write` to change) and the repository role to match. |
|
| 636 | + | - a person, with a token of theirs with `repo:write` and the write role on the repository, to read a state as much as to change one: states hold secrets, and every account reads a public or internal repository (oxygenhub#256). The Terraform state page lists the states only to people with the write role. |
|
| 637 | 637 | ||
| 638 | 638 | The token goes as the bearer or as Basic auth's password, which is what the backend sends (`TF_HTTP_PASSWORD`). |
|
| 639 | 639 | ||
| … | 172 unchanged lines |
modified templates/repo_terraform.html +3 −2
| … | 16 unchanged lines |
||
| 17 | 17 | <h2>From a pipeline</h2> |
|
| 18 | 18 | <p>A job uses it with an identity token of this hub's, so it needs no cloud credential for the state. Give the job <code>identity "{{ shell.base_url }}/terraform" {% raw %}{{% endraw %} mount-path "/run/secrets/tf" {% raw %}}{% endraw %}</code>, run <code>export TF_HTTP_PASSWORD=$(cat /run/secrets/tf)</code> before <code>tofu init</code>, and let its sandbox reach this hub (<code>network "egress" {% raw %}{{% endraw %} allow "{{ shell.base_url|host }}" {% raw %}}{% endraw %}</code>, beside whatever else it reaches).</p> |
|
| 19 | 19 | <h2>From your machine</h2> |
|
| 20 | - | <p>Set <code>TF_HTTP_PASSWORD</code> to one of your tokens: <code>repo:write</code> to change a state, <code>repo:read</code> to read one; the repository role it acts with decides the rest.</p> |
|
| 20 | + | <p>Set <code>TF_HTTP_PASSWORD</code> to one of your tokens with <code>repo:write</code>, as someone with the write role here. Reading a state needs that too: states hold secrets.</p> |
|
| 21 | 21 | <h2>States</h2> |
|
| 22 | - | {% if tf_states.is_empty() %}<p class="empty">No state yet: the first <code>tofu apply</code> through the backend makes one.</p>{% else %} |
|
| 22 | + | {% if !repo.can(crate::auth::Access::Write) %}<p class="mute">The states are shown to people who can write to this repository.</p> |
|
| 23 | + | {% else if tf_states.is_empty() %}<p class="empty">No state yet: the first <code>tofu apply</code> through the backend makes one.</p>{% else %} |
|
| 23 | 24 | <div class="table"><table> |
|
| 24 | 25 | <tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th></tr> |
|
| 25 | 26 | {% for (s, n) in tf_states %}<tr><td><strong>{{ s.name }}</strong></td><td>{{ s.serial }}</td><td class="small">{% if s.updated_by.is_empty() %}<span class="mute">never</span>{% else %}by {{ s.updated_by }} <time title="{{ s.updated_ms|when_i }}">{{ s.updated_ms|ago_i }}</time>{% endif %}</td><td class="small">{% if s.lock_id.is_empty() %}<span class="mute">free</span>{% else %}held by {{ s.locked_by }}{% if let Some(t) = s.locked_ms %} <time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td><td>{{ n }}</td></tr>{% endfor %} |
|
| … | 2 unchanged lines |