AkurAI Build
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));
    }
}