~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
164 lines · 5.5 KB Raw History
//! 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 })))
}