OXYGENHUB
# bb018142cca7 oxygenhub / crates / hub / src / terraform.rs
11.4 KiB · Rust · last changed Terraform state: reading one needs the write role 19 days ago source blame history raw
1
//! Terraform and OpenTofu state, kept by the hub per repository
2
//! (oxygenhub#252): the HTTP backend Terraform and OpenTofu speak, with
3
//! locking, at `/api/v1/repos/OWNER/NAME/terraform/state/STATE`, so a Flow
4
//! job keeps its state without any cloud credential. A job authenticates
5
//! with an identity token of this hub's for the audience `{base}/terraform`
6
//! (its pipeline's `identity` block) or its runner's job token, either for
7
//! its own repository only; people with a token of theirs and their
8
//! repository role. Tokens go as the bearer, or as the password of Basic
9
//! auth, which is what the backend sends.
10
//!
11
//! State is kept outside the repository's history (it often holds secrets),
12
//! sealed at rest with the hub's secrets key, and its last versions are
13
//! kept, so a bad apply can be rolled back.
14
15
use crate::api::ApiError;
16
use crate::auth::roles::{Access, Role};
17
use crate::{db, Shared};
18
use axum::body::Bytes;
19
use axum::extract::{Path, Query, State};
20
use axum::http::{header, HeaderMap, Method, StatusCode};
21
use axum::response::{IntoResponse, Response};
22
23
/// Versions of a state kept besides the current one.
24
pub const VERSIONS_KEPT: i64 = 20;
25
/// The most a state may weigh.
26
pub const MAX_STATE_BYTES: usize = 32 << 20;
27
28
/// Who may use a repository's states.
29
enum Who {
30
    /// A running job of this repository.
31
    Job { name: String },
32
    /// A person, with their role on the repository.
33
    Person { account: Box<db::accounts::Account>, role: Role },
34
}
35
36
impl Who {
37
    fn label(&self) -> String {
38
        match self {
39
            Who::Job { name } => name.clone(),
40
            Who::Person { account, .. } => account.username.clone(),
41
        }
42
    }
43
44
    fn may(&self, access: Access) -> bool {
45
        match self {
46
            // A job reads and writes its own repository's states.
47
            Who::Job { .. } => true,
48
            Who::Person { role, .. } => crate::auth::roles::allows(Some(*role), access),
49
        }
50
    }
51
}
52
53
fn err(status: StatusCode, why: impl Into<String>) -> ApiError {
54
    ApiError(status, why.into())
55
}
56
57
/// The token a request carries: the bearer, or Basic auth's password.
58
fn token_of(headers: &HeaderMap) -> Option<String> {
59
    if let Some(t) = oxy_server::bearer_token(headers) {
60
        return Some(t.to_string());
61
    }
62
    let v = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
63
    let b64 = v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))?;
64
    let raw = String::from_utf8(oxy_core::dsse::base64_decode(b64.trim())?).ok()?;
65
    let (_, password) = raw.split_once(':')?;
66
    (!password.is_empty()).then(|| password.to_string())
67
}
68
69
/// The audience a job's identity token needs here.
70
pub fn audience(base_url: &str) -> String {
71
    format!("{}/terraform", base_url.trim_end_matches('/'))
72
}
73
74
fn valid_name(name: &str) -> bool {
75
    !name.is_empty()
76
        && name.len() <= 100
77
        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
78
        && !name.starts_with('.')
79
}
80
81
async fn who(app: &Shared, headers: &HeaderMap, row: &db::repos::RepoRow) -> Result<Who, ApiError> {
82
    let token = token_of(headers).ok_or_else(|| {
83
        err(StatusCode::UNAUTHORIZED, "a Terraform state needs a token: a job's (TF_HTTP_PASSWORD), or one of yours with repo:write")
84
    })?;
85
    let hash = crate::auth::token_hash(&token);
86
    if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
87
        let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)
88
            .await
89
            .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
90
            .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"))?;
91
        let run = db::flow::run_by_id(&app.db, job.run_id)
92
            .await
93
            .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
94
            .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid"))?;
95
        if run.repo_id != row.id {
96
            return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only"));
97
        }
98
        return Ok(Who::Job { name: format!("run #{} {}", run.number, job.name) });
99
    }
100
    let personal = db::accounts::token_by_hash(&app.db, &hash).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
101
    let Some(token) = personal.filter(|t| !t.expired(db::now_ms())) else {
102
        // A job's identity token for this hub's Terraform audience.
103
        let issuer = app.oidc().await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
104
        let aud = audience(app.config.base_url());
105
        let Some(claims) = issuer.verify(&token, &aud, db::now_ms()) else {
106
            return Err(err(StatusCode::UNAUTHORIZED, format!("unknown or expired token (a job's identity token must be for {aud})")));
107
        };
108
        let sub = claims["sub"].as_str().unwrap_or("");
109
        let repo = claims["repository"]
110
            .as_str()
111
            .map(String::from)
112
            .or_else(|| sub.strip_prefix("repo:").and_then(|r| r.split_once(":bookmark:")).map(|(r, _)| r.to_string()));
113
        if repo.as_deref() != Some(row.slug().as_str()) {
114
            return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only"));
115
        }
116
        return Ok(Who::Job { name: sub.to_string() });
117
    };
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
    }
124
    let slug = row.slug();
125
    if !token.covers(&slug) {
126
        return Err(err(StatusCode::FORBIDDEN, format!("the token does not cover {slug}")));
127
    }
128
    let account = db::accounts::account_by_id(&app.db, token.account_id)
129
        .await
130
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
131
        .filter(|a| !a.banned && !a.disabled)
132
        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "unknown token"))?;
133
    let role = crate::auth::roles::role_for(&app.db, Some(&account), row)
134
        .await
135
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
136
    match role {
137
        Some(r) if crate::auth::roles::allows(Some(r), Access::Read) => Ok(Who::Person { account: Box::new(account), role: r }),
138
        _ => Err(err(StatusCode::NOT_FOUND, "no such repository")),
139
    }
140
}
141
142
#[derive(serde::Deserialize, Default)]
143
pub struct LockQuery {
144
    #[serde(rename = "ID", default)]
145
    id: Option<String>,
146
}
147
148
fn json(status: StatusCode, body: String) -> Response {
149
    (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
150
}
151
152
/// The lock's ID in a lock-info body (`{"ID": …}`).
153
fn lock_id(info: &[u8]) -> Option<String> {
154
    serde_json::from_slice::<serde_json::Value>(info).ok()?.get("ID")?.as_str().map(String::from)
155
}
156
157
/// `GET|POST|DELETE|LOCK|UNLOCK /api/v1/repos/{owner}/{name}/terraform/state/{state}`.
158
pub async fn state(
159
    State(app): State<Shared>,
160
    method: Method,
161
    headers: HeaderMap,
162
    Path((owner, name, state)): Path<(String, String, String)>,
163
    Query(q): Query<LockQuery>,
164
    body: Bytes,
165
) -> Result<Response, ApiError> {
166
    if !valid_name(&state) {
167
        return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100"));
168
    }
169
    let row = db::repos::get(&app.db, &owner, &name)
170
        .await
171
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
172
        .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?;
173
    let who = who(&app, &headers, &row).await?;
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"));
178
    }
179
    let db_err = |e: sqlx::Error| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
180
    let current = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?;
181
    let locked = current.as_ref().filter(|s| !s.lock_id.is_empty());
182
    match method.as_str() {
183
        "GET" => match current.filter(|s| !s.body.is_empty()) {
184
            Some(s) => {
185
                let plain =
186
                    app.secret_key.open(&s.body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the state does not open"))?;
187
                Ok(json(StatusCode::OK, plain))
188
            }
189
            // No state yet: Terraform starts afresh.
190
            None => Ok(StatusCode::NO_CONTENT.into_response()),
191
        },
192
        "POST" => {
193
            if body.len() > MAX_STATE_BYTES {
194
                return Err(err(StatusCode::PAYLOAD_TOO_LARGE, format!("a state may be {} MiB at most", MAX_STATE_BYTES >> 20)));
195
            }
196
            let text = std::str::from_utf8(&body).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?;
197
            let parsed: serde_json::Value = serde_json::from_str(text).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?;
198
            if let Some(l) = locked {
199
                if q.id.as_deref() != Some(l.lock_id.as_str()) {
200
                    return Ok(json(StatusCode::LOCKED, l.lock_info.clone()));
201
                }
202
            }
203
            let serial = parsed.get("serial").and_then(|v| v.as_i64()).unwrap_or(0);
204
            let sealed = app.secret_key.seal(text);
205
            db::terraform::put(&app.db, row.id, &state, &sealed, serial, &who.label(), VERSIONS_KEPT).await.map_err(db_err)?;
206
            Ok(StatusCode::OK.into_response())
207
        }
208
        "DELETE" => {
209
            if let Some(l) = locked {
210
                if q.id.as_deref() != Some(l.lock_id.as_str()) {
211
                    return Ok(json(StatusCode::LOCKED, l.lock_info.clone()));
212
                }
213
            }
214
            db::terraform::delete(&app.db, row.id, &state).await.map_err(db_err)?;
215
            Ok(StatusCode::OK.into_response())
216
        }
217
        "LOCK" => {
218
            let info = std::str::from_utf8(&body).unwrap_or("");
219
            let Some(id) = lock_id(body.as_ref()) else {
220
                return Err(err(StatusCode::BAD_REQUEST, "a lock request is the lock's JSON, with its ID"));
221
            };
222
            if let Some(l) = locked {
223
                return Ok(json(if l.lock_id == id { StatusCode::OK } else { StatusCode::LOCKED }, l.lock_info.clone()));
224
            }
225
            if db::terraform::lock(&app.db, row.id, &state, &id, info, &who.label()).await.map_err(db_err)? {
226
                Ok(json(StatusCode::OK, info.to_string()))
227
            } else {
228
                // Taken meanwhile.
229
                let now = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?;
230
                Ok(json(StatusCode::LOCKED, now.map(|s| s.lock_info).unwrap_or_default()))
231
            }
232
        }
233
        "UNLOCK" => {
234
            let Some(l) = locked else { return Ok(StatusCode::OK.into_response()) };
235
            // `terraform force-unlock` sends the ID; a plain unlock its lock info.
236
            let id = lock_id(body.as_ref()).or(q.id.clone());
237
            if id.as_deref() != Some(l.lock_id.as_str()) {
238
                return Ok(json(StatusCode::CONFLICT, l.lock_info.clone()));
239
            }
240
            db::terraform::unlock(&app.db, row.id, &state).await.map_err(db_err)?;
241
            Ok(StatusCode::OK.into_response())
242
        }
243
        other => {
244
            Err(err(StatusCode::METHOD_NOT_ALLOWED, format!("{other} is not a Terraform backend method (GET, POST, DELETE, LOCK, UNLOCK)")))
245
        }
246
    }
247
}