johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String, AppError> {
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<String>) -> Result<String, AppError> {
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<String>,
repo: String,
runner: String,
name: String,
#[serde(default, with = "knotless::secrets::patterns")]
refs: Vec<String>,
key: String,
#[serde(default, with = "knotless::secrets::bytes")]
sealed: Option<Vec<u8>>,
}
pub async fn put(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(body): Json<Put>,
) -> Result<Json<Value>, 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<String>,
repo: String,
name: Option<String>,
runner: Option<String>,
}
pub async fn delete(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(body): Json<Delete>,
) -> Result<Json<Value>, 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<Arc<App>>,
headers: HeaderMap,
Query(p): Query<ListParams>,
) -> Result<Json<Value>, 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 })))
}