Menu
Public repository / 8d41f1e10aceed387d425b43038f2070e399a349
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());
}
}