| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | |
|
| 17 | | use crate::api::ApiError; |
| 18 | | use crate::auth::roles::{Access, Role}; |
| 19 | | use crate::{db, Shared}; |
| 20 | | use axum::body::Bytes; |
| 21 | | use axum::extract::{Path, Query, State}; |
| 22 | | use axum::http::{header, HeaderMap, Method, StatusCode}; |
| 23 | | use axum::response::{IntoResponse, Response}; |
| 24 | |
|
| 25 | |
|
| 26 | | pub const VERSIONS_KEPT: i64 = 20; |
| 27 | |
|
| 28 | | pub const MAX_STATE_BYTES: usize = 32 << 20; |
| 29 | |
|
| 30 | |
|
| 31 | | enum Who { |
| 32 | | |
| 33 | | Job { name: String }, |
| 34 | | |
| 35 | | Person { account: Box<db::accounts::Account>, role: Role }, |
| 36 | | } |
| 37 | |
|
| 38 | | impl Who { |
| 39 | 4× | fn label(&self) -> String { |
| 40 | 4× | match self { |
| 41 | 4× | Who::Job { name } => name.clone(), |
| 42 | 0× | Who::Person { account, .. } => account.username.clone(), |
| 43 | | } |
| 44 | 4× | } |
| 45 | |
|
| 46 | 24× | fn may(&self, access: Access) -> bool { |
| 47 | 24× | match self { |
| 48 | | |
| 49 | 13× | Who::Job { .. } => true, |
| 50 | 11× | Who::Person { role, .. } => crate::auth::roles::allows(Some(*role), access), |
| 51 | | } |
| 52 | 24× | } |
| 53 | | } |
| 54 | |
|
| 55 | 12× | fn err(status: StatusCode, why: impl Into<String>) -> ApiError { |
| 56 | 12× | ApiError(status, why.into()) |
| 57 | 12× | } |
| 58 | |
|
| 59 | |
|
| 60 | 29× | fn token_of(headers: &HeaderMap) -> Option<String> { |
| 61 | 29× | if let Some(t) = oxy_server::bearer_token(headers) { |
| 62 | 15× | return Some(t.to_string()); |
| 63 | 14× | } |
| 64 | 14× | let v = headers.get(header::AUTHORIZATION)?.to_str().ok()?; |
| 65 | 13× | let b64 = v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))?; |
| 66 | 13× | let raw = String::from_utf8(oxy_core::dsse::base64_decode(b64.trim())?).ok()?; |
| 67 | 13× | let (_, password) = raw.split_once(':')?; |
| 68 | 13× | (!password.is_empty()).then(|| password.to_string()) |
| 69 | 29× | } |
| 70 | |
|
| 71 | |
|
| 72 | 5× | pub fn audience(base_url: &str) -> String { |
| 73 | 5× | format!("{}/terraform", base_url.trim_end_matches('/')) |
| 74 | 5× | } |
| 75 | |
|
| 76 | 30× | fn valid_name(name: &str) -> bool { |
| 77 | 30× | !name.is_empty() |
| 78 | 30× | && name.len() <= 100 |
| 79 | 119× | && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') |
| 80 | 30× | && !name.starts_with('.') |
| 81 | 30× | } |
| 82 | |
|
| 83 | 29× | async fn who(app: &Shared, headers: &HeaderMap, row: &db::repos::RepoRow) -> Result<Who, ApiError> { |
| 84 | 29× | let token = token_of(headers).ok_or_else(|| { |
| 85 | 1× | err(StatusCode::UNAUTHORIZED, "a Terraform state needs a token: a job's (TF_HTTP_PASSWORD), or one of yours with repo:write") |
| 86 | 1× | })?; |
| 87 | 28× | let hash = crate::auth::token_hash(&token); |
| 88 | 28× | if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
| 89 | 13× | let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS) |
| 90 | 13× | .await |
| 91 | 13× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 92 | 13× | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"))?; |
| 93 | 13× | let run = db::flow::run_by_id(&app.db, job.run_id) |
| 94 | 13× | .await |
| 95 | 13× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 96 | 13× | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid"))?; |
| 97 | 13× | if run.repo_id != row.id { |
| 98 | 1× | return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only")); |
| 99 | 12× | } |
| 100 | 12× | return Ok(Who::Job { name: format!("run #{} {}", run.number, job.name) }); |
| 101 | 15× | } |
| 102 | 15× | let personal = db::accounts::token_by_hash(&app.db, &hash).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 103 | 15× | let Some(token) = personal.filter(|t| !t.expired(db::now_ms())) else { |
| 104 | | |
| 105 | 4× | let issuer = app.oidc().await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 106 | 4× | let aud = audience(app.config.base_url()); |
| 107 | 4× | let Some(claims) = issuer.verify(&token, &aud, db::now_ms()) else { |
| 108 | 2× | return Err(err(StatusCode::UNAUTHORIZED, format!("unknown or expired token (a job's identity token must be for {aud})"))); |
| 109 | | }; |
| 110 | 2× | let sub = claims["sub"].as_str().unwrap_or(""); |
| 111 | 2× | let repo = claims["repository"] |
| 112 | 2× | .as_str() |
| 113 | 2× | .map(String::from) |
| 114 | 2× | .or_else(|| sub.strip_prefix("repo:").and_then(|r| r.split_once(":bookmark:")).map(|(r, _)| r.to_string())); |
| 115 | 2× | if repo.as_deref() != Some(row.slug().as_str()) { |
| 116 | 1× | return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only")); |
| 117 | 1× | } |
| 118 | 1× | return Ok(Who::Job { name: sub.to_string() }); |
| 119 | | }; |
| 120 | 11× | if !(token.has_scope("repo:write") || token.has_scope("repo:admin")) { |
| 121 | 0× | return Err(err( |
| 122 | 0× | StatusCode::FORBIDDEN, |
| 123 | 0× | "the token lacks repo:write: a Terraform state, read or written, needs it (states hold secrets)", |
| 124 | 0× | )); |
| 125 | 11× | } |
| 126 | 11× | let slug = row.slug(); |
| 127 | 11× | if !token.covers(&slug) { |
| 128 | 0× | return Err(err(StatusCode::FORBIDDEN, format!("the token does not cover {slug}"))); |
| 129 | 11× | } |
| 130 | 11× | let account = db::accounts::account_by_id(&app.db, token.account_id) |
| 131 | 11× | .await |
| 132 | 11× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 133 | 11× | .filter(|a| !a.banned && !a.disabled) |
| 134 | 11× | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "unknown token"))?; |
| 135 | 11× | let role = crate::auth::roles::role_for(&app.db, Some(&account), row) |
| 136 | 11× | .await |
| 137 | 11× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 138 | 11× | match role { |
| 139 | 11× | Some(r) if crate::auth::roles::allows(Some(r), Access::Read) => Ok(Who::Person { account: Box::new(account), role: r }), |
| 140 | 0× | _ => Err(err(StatusCode::NOT_FOUND, "no such repository")), |
| 141 | | } |
| 142 | 29× | } |
| 143 | |
|
| 144 | | #[derive(serde::Deserialize, Default)] |
| 145 | | pub struct LockQuery { |
| 146 | | #[serde(rename = "ID", default)] |
| 147 | | id: Option<String>, |
| 148 | | } |
| 149 | |
|
| 150 | 13× | fn json(status: StatusCode, body: String) -> Response { |
| 151 | 13× | (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() |
| 152 | 13× | } |
| 153 | |
|
| 154 | |
|
| 155 | 6× | fn lock_id(info: &[u8]) -> Option<String> { |
| 156 | 6× | serde_json::from_slice::<serde_json::Value>(info).ok()?.get("ID")?.as_str().map(String::from) |
| 157 | 6× | } |
| 158 | |
|
| 159 | |
|
| 160 | | pub enum RestoreError { |
| 161 | | |
| 162 | | NotFound, |
| 163 | | |
| 164 | | Locked(String), |
| 165 | | |
| 166 | | Broken, |
| 167 | | Db(sqlx::Error), |
| 168 | | } |
| 169 | |
|
| 170 | | impl RestoreError { |
| 171 | 2× | pub fn message(&self) -> String { |
| 172 | 2× | match self { |
| 173 | 1× | RestoreError::NotFound => "no such version of this state".into(), |
| 174 | 1× | RestoreError::Locked(by) => format!("the state is locked by {by}: restore it once the lock is released"), |
| 175 | 0× | RestoreError::Broken => "that version does not open: it cannot be restored".into(), |
| 176 | 0× | RestoreError::Db(e) => e.to_string(), |
| 177 | | } |
| 178 | 2× | } |
| 179 | |
|
| 180 | 2× | fn status(&self) -> StatusCode { |
| 181 | 2× | match self { |
| 182 | 1× | RestoreError::NotFound => StatusCode::NOT_FOUND, |
| 183 | 1× | RestoreError::Locked(_) => StatusCode::LOCKED, |
| 184 | 0× | RestoreError::Broken | RestoreError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, |
| 185 | | } |
| 186 | 2× | } |
| 187 | | } |
| 188 | |
|
| 189 | |
|
| 190 | |
|
| 191 | |
|
| 192 | |
|
| 193 | 5× | pub async fn restore(app: &Shared, repo_id: i64, state: &str, id: i64, by: &str) -> Result<i64, RestoreError> { |
| 194 | 5× | let current = db::terraform::get(&app.db, repo_id, state).await.map_err(RestoreError::Db)?; |
| 195 | 5× | if let Some(l) = current.as_ref().filter(|s| !s.lock_id.is_empty()) { |
| 196 | 1× | return Err(RestoreError::Locked(l.locked_by.clone())); |
| 197 | 4× | } |
| 198 | 3× | let (body, old_serial) = |
| 199 | 4× | db::terraform::version_body(&app.db, repo_id, state, id).await.map_err(RestoreError::Db)?.ok_or(RestoreError::NotFound)?; |
| 200 | 3× | let plain = app.secret_key.open(&body).ok_or(RestoreError::Broken)?; |
| 201 | 3× | let mut json: serde_json::Value = serde_json::from_str(&plain).map_err(|_| RestoreError::Broken)?; |
| 202 | 3× | let serial = current.map_or(0, |c| c.serial).max(old_serial) + 1; |
| 203 | 3× | json["serial"] = serial.into(); |
| 204 | 3× | let text = serde_json::to_string_pretty(&json).map_err(|_| RestoreError::Broken)?; |
| 205 | 3× | let label = format!("{by}, restoring serial {old_serial}"); |
| 206 | 3× | db::terraform::put(&app.db, repo_id, state, &app.secret_key.seal(&text), serial, &label, VERSIONS_KEPT) |
| 207 | 3× | .await |
| 208 | 3× | .map_err(RestoreError::Db)?; |
| 209 | 3× | Ok(serial) |
| 210 | 5× | } |
| 211 | |
|
| 212 | |
|
| 213 | |
|
| 214 | 3× | pub async fn versions( |
| 215 | 3× | State(app): State<Shared>, |
| 216 | 3× | headers: HeaderMap, |
| 217 | 3× | Path((owner, name, state)): Path<(String, String, String)>, |
| 218 | 3× | ) -> Result<Response, ApiError> { |
| 219 | 3× | let (row, _who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Write).await?; |
| 220 | 2× | let list = |
| 221 | 2× | db::terraform::version_list(&app.db, row.id, &state).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 222 | 2× | let out: Vec<serde_json::Value> = list |
| 223 | 2× | .iter() |
| 224 | 3× | .map(|v| serde_json::json!({ "id": v.id, "serial": v.serial, "written_by": v.created_by, "written_ms": v.created_ms })) |
| 225 | 2× | .collect(); |
| 226 | 2× | Ok(json(StatusCode::OK, serde_json::Value::Array(out).to_string())) |
| 227 | 3× | } |
| 228 | |
|
| 229 | |
|
| 230 | 1× | pub async fn version( |
| 231 | 1× | State(app): State<Shared>, |
| 232 | 1× | headers: HeaderMap, |
| 233 | 1× | Path((owner, name, state, id)): Path<(String, String, String, i64)>, |
| 234 | 1× | ) -> Result<Response, ApiError> { |
| 235 | 1× | let (row, _who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Write).await?; |
| 236 | 1× | let (body, _) = db::terraform::version_body(&app.db, row.id, &state, id) |
| 237 | 1× | .await |
| 238 | 1× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 239 | 1× | .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such version of this state"))?; |
| 240 | 1× | let plain = app.secret_key.open(&body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the version does not open"))?; |
| 241 | 1× | Ok(json(StatusCode::OK, plain)) |
| 242 | 1× | } |
| 243 | |
|
| 244 | |
|
| 245 | |
|
| 246 | 6× | pub async fn restore_version( |
| 247 | 6× | State(app): State<Shared>, |
| 248 | 6× | headers: HeaderMap, |
| 249 | 6× | Path((owner, name, state, id)): Path<(String, String, String, i64)>, |
| 250 | 6× | ) -> Result<Response, ApiError> { |
| 251 | 6× | let (row, who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Maintain).await?; |
| 252 | 5× | let Who::Person { account, .. } = &who else { |
| 253 | 1× | return Err(err(StatusCode::FORBIDDEN, "a job does not restore a state: a person with the maintain role does")); |
| 254 | | }; |
| 255 | 4× | let serial = restore(&app, row.id, &state, id, &account.username).await.map_err(|e| err(e.status(), e.message()))?; |
| 256 | 2× | db::accounts::audit( |
| 257 | 2× | &app.db, |
| 258 | 2× | Some(account), |
| 259 | 2× | "terraform.restored", |
| 260 | 2× | &format!("{}:{state}", row.slug()), |
| 261 | 2× | &format!("version {id}, now serial {serial}"), |
| 262 | 2× | ) |
| 263 | 2× | .await |
| 264 | 2× | .ok(); |
| 265 | 2× | Ok(json(StatusCode::OK, serde_json::json!({ "serial": serial }).to_string())) |
| 266 | 6× | } |
| 267 | |
|
| 268 | |
|
| 269 | 10× | async fn versions_caller( |
| 270 | 10× | app: &Shared, |
| 271 | 10× | headers: &HeaderMap, |
| 272 | 10× | owner: &str, |
| 273 | 10× | name: &str, |
| 274 | 10× | state: &str, |
| 275 | 10× | access: Access, |
| 276 | 10× | ) -> Result<(db::repos::RepoRow, Who), ApiError> { |
| 277 | 10× | if !valid_name(state) { |
| 278 | 0× | return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100")); |
| 279 | 10× | } |
| 280 | 10× | let row = db::repos::get(&app.db, owner, name) |
| 281 | 10× | .await |
| 282 | 10× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 283 | 10× | .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?; |
| 284 | 10× | let who = who(app, headers, &row).await?; |
| 285 | 10× | if !who.may(access) { |
| 286 | 2× | return Err(err( |
| 287 | | StatusCode::FORBIDDEN, |
| 288 | 2× | if access == Access::Maintain { "restoring a state needs the maintain role" } else { "a Terraform state needs the write role" }, |
| 289 | | )); |
| 290 | 8× | } |
| 291 | 8× | Ok((row, who)) |
| 292 | 10× | } |
| 293 | |
|
| 294 | |
|
| 295 | 20× | pub async fn state( |
| 296 | 20× | State(app): State<Shared>, |
| 297 | 20× | method: Method, |
| 298 | 20× | headers: HeaderMap, |
| 299 | 20× | Path((owner, name, state)): Path<(String, String, String)>, |
| 300 | 20× | Query(q): Query<LockQuery>, |
| 301 | 20× | body: Bytes, |
| 302 | 20× | ) -> Result<Response, ApiError> { |
| 303 | 20× | if !valid_name(&state) { |
| 304 | 1× | return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100")); |
| 305 | 19× | } |
| 306 | 19× | let row = db::repos::get(&app.db, &owner, &name) |
| 307 | 19× | .await |
| 308 | 19× | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 309 | 19× | .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?; |
| 310 | 19× | let who = who(&app, &headers, &row).await?; |
| 311 | | |
| 312 | | |
| 313 | 14× | if !who.may(Access::Write) { |
| 314 | 1× | return Err(err(StatusCode::FORBIDDEN, "a Terraform state needs the write role on its repository: states hold secrets")); |
| 315 | 13× | } |
| 316 | 13× | let db_err = |e: sqlx::Error| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); |
| 317 | 13× | let current = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?; |
| 318 | 13× | let locked = current.as_ref().filter(|s| !s.lock_id.is_empty()); |
| 319 | 13× | match method.as_str() { |
| 320 | 13× | "GET" => match current.filter(|s| !s.body.is_empty()) { |
| 321 | 3× | Some(s) => { |
| 322 | 3× | let plain = |
| 323 | 3× | app.secret_key.open(&s.body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the state does not open"))?; |
| 324 | 3× | Ok(json(StatusCode::OK, plain)) |
| 325 | | } |
| 326 | | |
| 327 | 1× | None => Ok(StatusCode::NO_CONTENT.into_response()), |
| 328 | | }, |
| 329 | 9× | "POST" => { |
| 330 | 3× | if body.len() > MAX_STATE_BYTES { |
| 331 | 0× | return Err(err(StatusCode::PAYLOAD_TOO_LARGE, format!("a state may be {} MiB at most", MAX_STATE_BYTES >> 20))); |
| 332 | 3× | } |
| 333 | 3× | let text = std::str::from_utf8(&body).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?; |
| 334 | 3× | let parsed: serde_json::Value = serde_json::from_str(text).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?; |
| 335 | 3× | if let Some(l) = locked { |
| 336 | 2× | if q.id.as_deref() != Some(l.lock_id.as_str()) { |
| 337 | 1× | return Ok(json(StatusCode::LOCKED, l.lock_info.clone())); |
| 338 | 1× | } |
| 339 | 1× | } |
| 340 | 2× | let serial = parsed.get("serial").and_then(|v| v.as_i64()).unwrap_or(0); |
| 341 | 2× | let sealed = app.secret_key.seal(text); |
| 342 | 2× | db::terraform::put(&app.db, row.id, &state, &sealed, serial, &who.label(), VERSIONS_KEPT).await.map_err(db_err)?; |
| 343 | 2× | Ok(StatusCode::OK.into_response()) |
| 344 | | } |
| 345 | 6× | "DELETE" => { |
| 346 | 0× | if let Some(l) = locked { |
| 347 | 0× | if q.id.as_deref() != Some(l.lock_id.as_str()) { |
| 348 | 0× | return Ok(json(StatusCode::LOCKED, l.lock_info.clone())); |
| 349 | 0× | } |
| 350 | 0× | } |
| 351 | 0× | db::terraform::delete(&app.db, row.id, &state).await.map_err(db_err)?; |
| 352 | 0× | Ok(StatusCode::OK.into_response()) |
| 353 | | } |
| 354 | 6× | "LOCK" => { |
| 355 | 3× | let info = std::str::from_utf8(&body).unwrap_or(""); |
| 356 | 3× | let Some(id) = lock_id(body.as_ref()) else { |
| 357 | 0× | return Err(err(StatusCode::BAD_REQUEST, "a lock request is the lock's JSON, with its ID")); |
| 358 | | }; |
| 359 | 3× | if let Some(l) = locked { |
| 360 | 1× | return Ok(json(if l.lock_id == id { StatusCode::OK } else { StatusCode::LOCKED }, l.lock_info.clone())); |
| 361 | 2× | } |
| 362 | 2× | if db::terraform::lock(&app.db, row.id, &state, &id, info, &who.label()).await.map_err(db_err)? { |
| 363 | 2× | Ok(json(StatusCode::OK, info.to_string())) |
| 364 | | } else { |
| 365 | | |
| 366 | 0× | let now = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?; |
| 367 | 0× | Ok(json(StatusCode::LOCKED, now.map(|s| s.lock_info).unwrap_or_default())) |
| 368 | | } |
| 369 | | } |
| 370 | 3× | "UNLOCK" => { |
| 371 | 3× | let Some(l) = locked else { return Ok(StatusCode::OK.into_response()) }; |
| 372 | | |
| 373 | 3× | let id = lock_id(body.as_ref()).or(q.id.clone()); |
| 374 | 3× | if id.as_deref() != Some(l.lock_id.as_str()) { |
| 375 | 1× | return Ok(json(StatusCode::CONFLICT, l.lock_info.clone())); |
| 376 | 2× | } |
| 377 | 2× | db::terraform::unlock(&app.db, row.id, &state).await.map_err(db_err)?; |
| 378 | 2× | Ok(StatusCode::OK.into_response()) |
| 379 | | } |
| 380 | 0× | other => { |
| 381 | 0× | Err(err(StatusCode::METHOD_NOT_ALLOWED, format!("{other} is not a Terraform backend method (GET, POST, DELETE, LOCK, UNLOCK)"))) |
| 382 | | } |
| 383 | | } |
| 384 | 20× | } |