OXYGENHUB
# 85ec74fd82d8 oxygenhub / crates / hub / src / terraform.rs
11.5 KiB · Rust · last changed The hub keeps Terraform and OpenTofu state (closes #252) 18 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(
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
        )
87
    })?;
88
    let hash = crate::auth::token_hash(&token);
89
    if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
90
        let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)
91
            .await
92
            .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
93
            .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"))?;
94
        let run = db::flow::run_by_id(&app.db, job.run_id)
95
            .await
96
            .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
97
            .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid"))?;
98
        if run.repo_id != row.id {
99
            return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only"));
100
        }
101
        return Ok(Who::Job { name: format!("run #{} {}", run.number, job.name) });
102
    }
103
    let personal = db::accounts::token_by_hash(&app.db, &hash).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
104
    let Some(token) = personal.filter(|t| !t.expired(db::now_ms())) else {
105
        // A job's identity token for this hub's Terraform audience.
106
        let issuer = app.oidc().await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
107
        let aud = audience(app.config.base_url());
108
        let Some(claims) = issuer.verify(&token, &aud, db::now_ms()) else {
109
            return Err(err(StatusCode::UNAUTHORIZED, format!("unknown or expired token (a job's identity token must be for {aud})")));
110
        };
111
        let sub = claims["sub"].as_str().unwrap_or("");
112
        let repo = claims["repository"]
113
            .as_str()
114
            .map(String::from)
115
            .or_else(|| sub.strip_prefix("repo:").and_then(|r| r.split_once(":bookmark:")).map(|(r, _)| r.to_string()));
116
        if repo.as_deref() != Some(row.slug().as_str()) {
117
            return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only"));
118
        }
119
        return Ok(Who::Job { name: sub.to_string() });
120
    };
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)"));
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
    // 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
    match role {
142
        Some(r) if crate::auth::roles::allows(Some(r), Access::Read) => Ok(Who::Person { account: Box::new(account), role: r }),
143
        _ => Err(err(StatusCode::NOT_FOUND, "no such repository")),
144
    }
145
}
146
147
#[derive(serde::Deserialize, Default)]
148
pub struct LockQuery {
149
    #[serde(rename = "ID", default)]
150
    id: Option<String>,
151
}
152
153
fn json(status: StatusCode, body: String) -> Response {
154
    (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
155
}
156
157
/// The lock's ID in a lock-info body (`{"ID": …}`).
158
fn lock_id(info: &[u8]) -> Option<String> {
159
    serde_json::from_slice::<serde_json::Value>(info).ok()?.get("ID")?.as_str().map(String::from)
160
}
161
162
/// `GET|POST|DELETE|LOCK|UNLOCK /api/v1/repos/{owner}/{name}/terraform/state/{state}`.
163
pub async fn state(
164
    State(app): State<Shared>,
165
    method: Method,
166
    headers: HeaderMap,
167
    Path((owner, name, state)): Path<(String, String, String)>,
168
    Query(q): Query<LockQuery>,
169
    body: Bytes,
170
) -> Result<Response, ApiError> {
171
    if !valid_name(&state) {
172
        return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100"));
173
    }
174
    let row = db::repos::get(&app.db, &owner, &name)
175
        .await
176
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
177
        .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?;
178
    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"));
182
    }
183
    let db_err = |e: sqlx::Error| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
184
    let current = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?;
185
    let locked = current.as_ref().filter(|s| !s.lock_id.is_empty());
186
    match method.as_str() {
187
        "GET" => match current.filter(|s| !s.body.is_empty()) {
188
            Some(s) => {
189
                let plain =
190
                    app.secret_key.open(&s.body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the state does not open"))?;
191
                Ok(json(StatusCode::OK, plain))
192
            }
193
            // No state yet: Terraform starts afresh.
194
            None => Ok(StatusCode::NO_CONTENT.into_response()),
195
        },
196
        "POST" => {
197
            if body.len() > MAX_STATE_BYTES {
198
                return Err(err(StatusCode::PAYLOAD_TOO_LARGE, format!("a state may be {} MiB at most", MAX_STATE_BYTES >> 20)));
199
            }
200
            let text = std::str::from_utf8(&body).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?;
201
            let parsed: serde_json::Value = serde_json::from_str(text).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?;
202
            if let Some(l) = locked {
203
                if q.id.as_deref() != Some(l.lock_id.as_str()) {
204
                    return Ok(json(StatusCode::LOCKED, l.lock_info.clone()));
205
                }
206
            }
207
            let serial = parsed.get("serial").and_then(|v| v.as_i64()).unwrap_or(0);
208
            let sealed = app.secret_key.seal(text);
209
            db::terraform::put(&app.db, row.id, &state, &sealed, serial, &who.label(), VERSIONS_KEPT).await.map_err(db_err)?;
210
            Ok(StatusCode::OK.into_response())
211
        }
212
        "DELETE" => {
213
            if let Some(l) = locked {
214
                if q.id.as_deref() != Some(l.lock_id.as_str()) {
215
                    return Ok(json(StatusCode::LOCKED, l.lock_info.clone()));
216
                }
217
            }
218
            db::terraform::delete(&app.db, row.id, &state).await.map_err(db_err)?;
219
            Ok(StatusCode::OK.into_response())
220
        }
221
        "LOCK" => {
222
            let info = std::str::from_utf8(&body).unwrap_or("");
223
            let Some(id) = lock_id(body.as_ref()) else {
224
                return Err(err(StatusCode::BAD_REQUEST, "a lock request is the lock's JSON, with its ID"));
225
            };
226
            if let Some(l) = locked {
227
                return Ok(json(if l.lock_id == id { StatusCode::OK } else { StatusCode::LOCKED }, l.lock_info.clone()));
228
            }
229
            if db::terraform::lock(&app.db, row.id, &state, &id, info, &who.label()).await.map_err(db_err)? {
230
                Ok(json(StatusCode::OK, info.to_string()))
231
            } else {
232
                // Taken meanwhile.
233
                let now = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?;
234
                Ok(json(StatusCode::LOCKED, now.map(|s| s.lock_info).unwrap_or_default()))
235
            }
236
        }
237
        "UNLOCK" => {
238
            let Some(l) = locked else { return Ok(StatusCode::OK.into_response()) };
239
            // `terraform force-unlock` sends the ID; a plain unlock its lock info.
240
            let id = lock_id(body.as_ref()).or(q.id.clone());
241
            if id.as_deref() != Some(l.lock_id.as_str()) {
242
                return Ok(json(StatusCode::CONFLICT, l.lock_info.clone()));
243
            }
244
            db::terraform::unlock(&app.db, row.id, &state).await.map_err(db_err)?;
245
            Ok(StatusCode::OK.into_response())
246
        }
247
        other => {
248
            Err(err(StatusCode::METHOD_NOT_ALLOWED, format!("{other} is not a Terraform backend method (GET, POST, DELETE, LOCK, UNLOCK)")))
249
        }
250
    }
251
}