Menu
Public repository / 8d41f1e10aceed387d425b43038f2070e399a349
AkurAI-Build / src/server/secrets_api.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): names and kinds are readable by repository
//! admins, values are write-only, and schedules are listed from the pipeline.
use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
};
use serde::Deserialize;
use super::access_api::{identify, role_on};
use super::{ApiError, AppState, Envelope, ok, unix_time};
use crate::{
access::Role,
config::Schedule,
schedule,
secrets::{self, SecretInfo},
};
fn require(
headers: &HeaderMap,
state: &AppState,
repository: &str,
role: Role,
) -> Result<(), ApiError> {
let identity = identify(headers, state)?;
match role_on(&identity, state, repository)? {
Some(held) if held.allows(role) => Ok(()),
Some(_) => Err(ApiError::forbidden(format!(
"{} access to {repository} is required",
role.as_str()
))),
None => Err(ApiError::forbidden(format!("no access to {repository}"))),
}
}
fn known(state: &AppState, repository: &str) -> Result<(), ApiError> {
state
.database
.repository(repository)
.map(|_| ())
.map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))
}
pub(super) async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<SecretInfo>>>, ApiError> {
known(&state, &repository)?;
require(&headers, &state, &repository, Role::Admin)?;
Ok(ok(
secrets::list(&state.database, &repository).map_err(ApiError::internal)?
))
}
#[derive(Deserialize)]
pub(super) struct SecretRequest {
value: Option<String>,
passvault: Option<String>,
}
pub(super) async fn put(
State(state): State<AppState>,
headers: HeaderMap,
Path((repository, name)): Path<(String, String)>,
Json(body): Json<SecretRequest>,
) -> Result<(StatusCode, Json<Envelope<SecretInfo>>), ApiError> {
known(&state, &repository)?;
require(&headers, &state, &repository, Role::Admin)?;
let now = unix_time()? as i64;
let info = match (body.value, body.passvault) {
(Some(value), None) => secrets::set_value(&state.database, &repository, &name, &value, now),
(None, Some(entry)) => {
secrets::set_passvault(&state.database, &repository, &name, &entry, now)
}
_ => return Err(ApiError::bad("send exactly one of value or passvault")),
}
.map_err(ApiError::bad)?;
Ok((StatusCode::CREATED, ok(info)))
}
pub(super) async fn remove(
State(state): State<AppState>,
headers: HeaderMap,
Path((repository, name)): Path<(String, String)>,
) -> Result<Json<Envelope<serde_json::Value>>, ApiError> {
known(&state, &repository)?;
require(&headers, &state, &repository, Role::Admin)?;
if !secrets::remove(&state.database, &repository, &name).map_err(ApiError::internal)? {
return Err(ApiError::not_found(format!(
"no secret {name} on {repository}"
)));
}
Ok(ok(serde_json::json!({ "name": name, "removed": true })))
}
/// The cron schedules the repository's default-branch pipeline declares.
pub(super) async fn schedules(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<Schedule>>>, ApiError> {
let repo = state
.database
.repository(&repository)
.map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))?;
if repo.visibility != "public" {
require(&headers, &state, &repository, Role::Read)?;
}
let database = state.database.clone();
let root = state.hosted_root.clone();
let found = state
.blocking_git
.run(move || schedule::schedules_of(&root, &database, &repository).map_err(ApiError::bad))
.await?;
Ok(ok(found))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, header},
routing::get,
};
use tower::ServiceExt;
async fn send(
app: &Router,
method: &str,
uri: &str,
auth: Option<&str>,
body: serde_json::Value,
) -> (StatusCode, serde_json::Value) {
let mut request = Request::builder()
.method(method)
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(auth) = auth {
request = request.header(header::AUTHORIZATION, auth);
}
let response = app
.clone()
.oneshot(request.body(Body::from(body.to_string())).expect("request"))
.await
.expect("response");
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
(
status,
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
)
}
#[tokio::test]
async fn secrets_need_admin_and_never_return_values() {
let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
state
.database
.add_repository("demo", "https://example.com/demo.git", "main")
.expect("add");
crate::access::set_collaborator(&state.database, "demo", "writer@example", Role::Write)
.expect("collab");
let now = unix_time().ok().expect("now") as i64;
let (_, writer) = crate::access::create_token(
&state.database,
&crate::access::NewToken {
kind: crate::access::Kind::Pat,
name: "w",
subject: Some("writer@example"),
repository: None,
role: Role::Admin,
ttl_secs: None,
},
now,
)
.expect("pat");
let app = Router::new()
.route("/api/repos/{repository}/secrets", get(list))
.route(
"/api/repos/{repository}/secrets/{name}",
axum::routing::put(put).delete(remove),
)
.route("/api/repos/{repository}/schedules", get(schedules))
.with_state(state.clone());
let master = Some("Bearer api-token-123");
let writer = format!("Bearer {writer}");
let (status, _) = send(
&app,
"PUT",
"/api/repos/demo/secrets/API_KEY",
Some(&writer),
serde_json::json!({"value": "x"}),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"writers cannot manage secrets"
);
let (status, body) = send(
&app,
"PUT",
"/api/repos/demo/secrets/API_KEY",
master,
serde_json::json!({"value": "s3cret"}),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["data"]["kind"], "value");
assert!(!body.to_string().contains("s3cret"));
let (status, body) = send(
&app,
"PUT",
"/api/repos/demo/secrets/TOKEN",
master,
serde_json::json!({"passvault": "deploy-token"}),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{body}");
assert_eq!(body["data"]["passvault"], "deploy-token");
let (status, body) = send(
&app,
"PUT",
"/api/repos/demo/secrets/BOTH",
master,
serde_json::json!({"value": "a", "passvault": "b"}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
let (status, body) = send(
&app,
"PUT",
"/api/repos/demo/secrets/bad-name",
master,
serde_json::json!({"value": "a"}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
let (status, body) = send(
&app,
"GET",
"/api/repos/demo/secrets",
master,
serde_json::Value::Null,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"].as_array().map(Vec::len), Some(2), "{body}");
assert!(!body.to_string().contains("s3cret"));
let (status, _) = send(
&app,
"DELETE",
"/api/repos/demo/secrets/API_KEY",
master,
serde_json::Value::Null,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = send(
&app,
"DELETE",
"/api/repos/demo/secrets/API_KEY",
master,
serde_json::Value::Null,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) = send(
&app,
"GET",
"/api/repos/nope/secrets",
master,
serde_json::Value::Null,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
// schedules: readable with read; an unhosted repository has none
let (status, body) = send(
&app,
"GET",
"/api/repos/demo/schedules",
Some(&writer),
serde_json::Value::Null,
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["data"].as_array().map(Vec::len), Some(0));
}
}