AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/secrets.rs

Latest change 8d41f1e10aceed387d425b43038f2070e399a349 - OLI-277: schedules, reusable workflows (include + inputs), per-repository secrets (value or PassVault), native caches by Ólafur Búi Ólafsson

//! Per-repository secrets (OLI-277). A secret is either a value kept in the
//! forge's encrypted database or the name of an akurai-secret (PassVault) entry
//! read at job time. Values are write-only through the API; jobs receive them
//! as `AKURAI_SECRET_<NAME>` only when they declare the name, and the runner
//! redacts them from retained logs as before.

use std::{collections::BTreeMap, process::Command};

use anyhow::{Context, Result, ensure};
use rusqlite::{OptionalExtension, params};
use serde::Serialize;

use crate::db::Database;

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct SecretInfo {
    pub name: String,
    /// `value` (stored here) or `passvault` (read from akurai-secret at job time).
    pub kind: String,
    /// The PassVault entry name, for `passvault` secrets.
    pub passvault: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
}

pub fn valid_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 64
        && name
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
        && !name.as_bytes()[0].is_ascii_digit()
}

fn repository_id(database: &Database, repository: &str) -> Result<i64> {
    Ok(database.repository(repository)?.id)
}

/// Store a value for `name` (replacing any previous value or PassVault link).
pub fn set_value(
    database: &Database,
    repository: &str,
    name: &str,
    value: &str,
    now: i64,
) -> Result<SecretInfo> {
    ensure!(
        valid_name(name),
        "secret names are UPPER_SNAKE_CASE, at most 64 bytes"
    );
    ensure!(
        !value.is_empty() && value.len() <= 16_384,
        "secret value must be 1 to 16384 bytes"
    );
    let id = repository_id(database, repository)?;
    database.connection()?.execute(
        "INSERT INTO repository_secrets(repository_id, name, value, passvault, created_at, updated_at)
         VALUES (?1, ?2, ?3, NULL, ?4, ?4)
         ON CONFLICT(repository_id, name) DO UPDATE SET value = excluded.value, passvault = NULL, updated_at = excluded.updated_at",
        params![id, name, value, now],
    )?;
    info(database, repository, name)?.context("secret vanished")
}

/// Link `name` to the PassVault entry `entry`, read with `akurai-secret get` when a job runs.
pub fn set_passvault(
    database: &Database,
    repository: &str,
    name: &str,
    entry: &str,
    now: i64,
) -> Result<SecretInfo> {
    ensure!(
        valid_name(name),
        "secret names are UPPER_SNAKE_CASE, at most 64 bytes"
    );
    ensure!(
        !entry.is_empty()
            && entry.len() <= 128
            && !entry.starts_with('-')
            && entry
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b"._-:@".contains(&b)),
        "PassVault entry names are 1 to 128 bytes of letters, digits, . _ - : @"
    );
    let id = repository_id(database, repository)?;
    database.connection()?.execute(
        "INSERT INTO repository_secrets(repository_id, name, value, passvault, created_at, updated_at)
         VALUES (?1, ?2, NULL, ?3, ?4, ?4)
         ON CONFLICT(repository_id, name) DO UPDATE SET value = NULL, passvault = excluded.passvault, updated_at = excluded.updated_at",
        params![id, name, entry, now],
    )?;
    info(database, repository, name)?.context("secret vanished")
}

pub fn info(database: &Database, repository: &str, name: &str) -> Result<Option<SecretInfo>> {
    let id = repository_id(database, repository)?;
    Ok(database
        .connection()?
        .query_row(
            "SELECT name, value IS NOT NULL, passvault, created_at, updated_at FROM repository_secrets WHERE repository_id = ?1 AND name = ?2",
            params![id, name],
            map_info,
        )
        .optional()?)
}

pub fn list(database: &Database, repository: &str) -> Result<Vec<SecretInfo>> {
    let id = repository_id(database, repository)?;
    let connection = database.connection()?;
    let mut statement = connection.prepare(
        "SELECT name, value IS NOT NULL, passvault, created_at, updated_at FROM repository_secrets WHERE repository_id = ?1 ORDER BY name",
    )?;
    Ok(statement
        .query_map([id], map_info)?
        .collect::<rusqlite::Result<_>>()?)
}

pub fn remove(database: &Database, repository: &str, name: &str) -> Result<bool> {
    let id = repository_id(database, repository)?;
    Ok(database.connection()?.execute(
        "DELETE FROM repository_secrets WHERE repository_id = ?1 AND name = ?2",
        params![id, name],
    )? == 1)
}

fn map_info(row: &rusqlite::Row<'_>) -> rusqlite::Result<SecretInfo> {
    let stored: bool = row.get(1)?;
    Ok(SecretInfo {
        name: row.get(0)?,
        kind: if stored { "value" } else { "passvault" }.into(),
        passvault: row.get(2)?,
        created_at: row.get(3)?,
        updated_at: row.get(4)?,
    })
}

/// Reads PassVault entries; the real one runs `akurai-secret get NAME`.
pub trait Vault: Send + Sync {
    fn get(&self, entry: &str) -> Result<String>;
}

pub struct CommandVault(pub String);

impl Vault for CommandVault {
    fn get(&self, entry: &str) -> Result<String> {
        let output = Command::new(&self.0)
            .args(["get", entry])
            .output()
            .with_context(|| format!("run {} get", self.0))?;
        ensure!(
            output.status.success(),
            "PassVault has no readable entry {entry}: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
        let value = String::from_utf8(output.stdout).context("PassVault value is not UTF-8")?;
        let value = value.trim_end_matches(['\n', '\r']).to_owned();
        ensure!(!value.is_empty(), "PassVault entry {entry} is empty");
        Ok(value)
    }
}

/// Resolve the secrets a job declared: the repository's own first (stored
/// value or PassVault entry), then the worker's `AKURAI_SECRET_<NAME>`.
pub fn resolve(
    database: &Database,
    repository: &str,
    names: &[String],
    vault: &dyn Vault,
) -> Result<BTreeMap<String, String>> {
    let id = repository_id(database, repository)?;
    let mut out = BTreeMap::new();
    for name in names {
        let row: Option<(Option<String>, Option<String>)> = database
            .connection()?
            .query_row(
                "SELECT value, passvault FROM repository_secrets WHERE repository_id = ?1 AND name = ?2",
                params![id, name],
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .optional()?;
        let value = match row {
            Some((Some(value), _)) => value,
            Some((None, Some(entry))) => vault.get(&entry)?,
            _ => std::env::var(format!("AKURAI_SECRET_{name}")).with_context(|| {
                format!(
                    "secret {name}: not set on the repository and AKURAI_SECRET_{name} is missing"
                )
            })?,
        };
        out.insert(name.clone(), value);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

    struct FakeVault;
    impl Vault for FakeVault {
        fn get(&self, entry: &str) -> Result<String> {
            ensure!(entry == "deploy-token", "unknown entry {entry}");
            Ok("from-vault".into())
        }
    }

    #[test]
    fn secrets_are_stored_listed_without_values_and_resolved_in_order() {
        let db = Database::memory(KEY).expect("db");
        db.add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        assert!(set_value(&db, "demo", "lower", "x", 1).is_err());
        assert!(set_value(&db, "demo", "EMPTY", "", 1).is_err());
        assert!(set_passvault(&db, "demo", "BAD", "-rf", 1).is_err());
        let stored = set_value(&db, "demo", "API_KEY", "s3cret", 1).expect("set");
        assert_eq!(
            (stored.kind.as_str(), stored.passvault.as_deref()),
            ("value", None)
        );
        let linked = set_passvault(&db, "demo", "DEPLOY_TOKEN", "deploy-token", 2).expect("link");
        assert_eq!(
            (linked.kind.as_str(), linked.passvault.as_deref()),
            ("passvault", Some("deploy-token"))
        );
        let listed = list(&db, "demo").expect("list");
        assert_eq!(
            listed.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
            ["API_KEY", "DEPLOY_TOKEN"]
        );
        assert!(
            !serde_json::to_string(&listed)
                .expect("json")
                .contains("s3cret"),
            "values never serialise"
        );

        let resolved = resolve(
            &db,
            "demo",
            &["API_KEY".into(), "DEPLOY_TOKEN".into()],
            &FakeVault,
        )
        .expect("resolve");
        assert_eq!(resolved["API_KEY"], "s3cret");
        assert_eq!(resolved["DEPLOY_TOKEN"], "from-vault");
        let missing =
            resolve(&db, "demo", &["NOPE_OLI277".into()], &FakeVault).expect_err("missing");
        assert!(
            missing.to_string().contains("AKURAI_SECRET_NOPE_OLI277"),
            "{missing}"
        );

        // replacing a value with a vault link and back
        set_passvault(&db, "demo", "API_KEY", "deploy-token", 3).expect("relink");
        assert_eq!(
            resolve(&db, "demo", &["API_KEY".into()], &FakeVault).expect("r")["API_KEY"],
            "from-vault"
        );
        set_value(&db, "demo", "API_KEY", "again", 4).expect("set");
        assert_eq!(
            info(&db, "demo", "API_KEY")
                .expect("info")
                .map(|s| s.updated_at),
            Some(4)
        );
        assert!(remove(&db, "demo", "API_KEY").expect("remove"));
        assert!(!remove(&db, "demo", "API_KEY").expect("remove"));
        assert!(list(&db, "missing").is_err());
    }
}