//! Secrets owners leave for their repositories' runners //! ([`knotless::secrets`]), each sealed to a runner's key, so this server //! holds only ciphertext. An owner puts, lists and deletes its own, as an //! admin who joined an organization does the organization's; a runner //! lists those sealed to it. Each signs for itself with a service token, //! or with the pages' login, which seal each value in the browser; a //! repository is named by its owner and key, never its name. use crate::gateway::caller; use crate::moderation::{bad, index}; use crate::viewer::Viewer; use crate::{App, AppError}; use axum::Json; use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode, header}; use knotless::records::RerunVar; use knotless::secrets::{DELETE, LIST, PUT, REFS_MAX, Secret, VALUE_MAX}; use serde::Deserialize; use serde_json::{Value, json}; use std::sync::Arc; /// The most a sealed value takes: the encapsulated key, the value, and the /// AEAD's tag. const SEALED_MAX: usize = 65 + VALUE_MAX + 16; /// Who asks: the account a service token for `lxm` is from, or, sent /// with none, the one logged in to the pages. async fn asker(app: &App, headers: &HeaderMap, lxm: &str) -> Result { if headers.contains_key(header::AUTHORIZATION) { return caller(app, headers, lxm).await; } let viewer = Viewer(crate::login::viewer(app, headers)); crate::web::logged_in(app, viewer, headers, "manage a repository's secrets") } /// Whose repository `asker` names: `owner`'s, if they may manage its /// secrets, as its owner or an admin who joined it as an organization; or /// their own, with none named. fn managed(app: &App, asker: String, owner: Option) -> Result { let Some(owner) = owner.filter(|o| *o != asker) else { return Ok(asker); }; match app.org_admits(&owner, &asker) { true => Ok(owner), false => Err(AppError( StatusCode::FORBIDDEN, format!("{asker} is not an admin of {owner}"), )), } } /// Whether `repo` could be a repository's key: a TID, or a key as long. fn valid_repo(repo: &str) -> bool { (1..=32).contains(&repo.len()) && repo.bytes().all(|b| b.is_ascii_alphanumeric()) } #[derive(Deserialize)] pub struct Put { owner: Option, repo: String, runner: String, name: String, #[serde(default, with = "knotless::secrets::patterns")] refs: Vec, key: String, #[serde(default, with = "knotless::secrets::bytes")] sealed: Option>, } pub async fn put( State(app): State>, headers: HeaderMap, Json(body): Json, ) -> Result, AppError> { let index = index(&app)?; let asker = asker(&app, &headers, PUT).await?; let owner = managed(&app, asker, body.owner)?; if !valid_repo(&body.repo) { return Err(bad("a repository is named by its key")); } if !body.runner.starts_with("did:") || !body.key.starts_with("did:key:") { return Err(bad("a runner and its key are named by their DIDs")); } if !RerunVar::valid_name(&body.name) { return Err(bad( "a secret's name is a shell variable's, not one starting KNOTLESS_RUN", )); } let refs_ok = body.refs.iter().all(|r| (1..=256).contains(&r.len())); if body.refs.len() > REFS_MAX || !refs_ok { return Err(bad(format!( "at most {REFS_MAX} refs, each a ref or a prefix" ))); } let sealed = body.sealed.unwrap_or_default(); if sealed.is_empty() || sealed.len() > SEALED_MAX { return Err(bad(format!("a sealed value is at most {SEALED_MAX} bytes"))); } let secret = Secret { name: body.name, runner: body.runner, refs: body.refs, key: body.key, sealed: Some(sealed), updated_at: knotless::records::now(), }; index .put_secret(&owner, &body.repo, &secret) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; Ok(Json(json!({}))) } #[derive(Deserialize)] pub struct Delete { owner: Option, repo: String, name: Option, runner: Option, } pub async fn delete( State(app): State>, headers: HeaderMap, Json(body): Json, ) -> Result, AppError> { let index = index(&app)?; let asker = asker(&app, &headers, DELETE).await?; let owner = managed(&app, asker, body.owner)?; let (name, runner) = (body.name.as_deref(), body.runner.as_deref()); let deleted = index.delete_secrets(&owner, &body.repo, name, runner); Ok(Json( json!({ "deleted": deleted.map_err(anyhow::Error::from).map_err(AppError::internal)? }), )) } #[derive(Deserialize)] pub struct ListParams { owner: String, repo: String, } pub async fn list( State(app): State>, headers: HeaderMap, Query(p): Query, ) -> Result, AppError> { let index = index(&app)?; let me = asker(&app, &headers, LIST).await?; let mut secrets = index .secrets(&p.owner, &p.repo) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; // Its owner, or an organization's admin, sees every one, and no value // but those sealed to it, as a runner that logs in as either; a runner, // only its own. if app.org_admits(&p.owner, &me) { let others = secrets.iter_mut().filter(|s| s.runner != me); others.for_each(|s| s.sealed = None); } else { secrets.retain(|s| s.runner == me); } Ok(Json(json!({ "secrets": secrets }))) }