OXYGENHUB

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 &quot;") || 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