AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/server/access_api.rs

Latest change 256514a7119cf865b403051263f3b75f8e9d13d1 - OLI-271: users, collaborator roles, deploy keys and scoped PATs on the forge by Óli

//! Who is calling (OLI-271): the controller token, a browser session (an IdP
//! identity), a personal access token or a deploy key; what they may do on a
//! repository; and the routes that manage collaborators, tokens and keys.

use axum::{
    Json,
    extract::{Path, Query, State},
    http::{HeaderMap, header},
};
use serde::{Deserialize, Serialize};

use super::{ApiError, AppState, Envelope, authorize_mutation, ok, request_cookie, unix_time};
use crate::access::{self, Collaborator, Kind, NewToken, Principal, Role, TokenInfo};

/// The caller as the forge sees it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Identity {
    /// The controller API token: everything.
    Master,
    /// A signed-in person (IdP); `admin` is the forge admin allowlist.
    Session { email: String, admin: bool },
    /// A PAT or deploy key: what its role allows on its repository.
    Token(Principal),
}

impl Identity {
    /// The subject written to git's `REMOTE_USER` and used for collaborator lookups.
    pub(super) fn subject(&self) -> String {
        match self {
            Identity::Master => "forge".into(),
            Identity::Session { email, .. } => email.clone(),
            Identity::Token(p) => p
                .subject
                .clone()
                .unwrap_or_else(|| format!("deploy-key-{}", p.token_id)),
        }
    }
}

/// Decode standard base64 (with or without padding); `None` on bad input.
pub(super) fn base64_decode(text: &str) -> Option<Vec<u8>> {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = Vec::with_capacity(text.len() * 3 / 4);
    let mut buffer = 0u32;
    let mut bits = 0u32;
    for byte in text.bytes() {
        if byte == b'=' || byte == b'\n' || byte == b'\r' {
            continue;
        }
        let value = TABLE.iter().position(|&c| c == byte)? as u32;
        buffer = (buffer << 6) | value;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push(((buffer >> bits) & 0xff) as u8);
        }
    }
    Some(out)
}

fn session_fields(cookie: &str, secret: &str) -> Option<(String, String, bool)> {
    let (encoded_payload, supplied_signature) = cookie.rsplit_once('.')?;
    let payload = super::hex_to_text(encoded_payload)?;
    let expected = super::sign_with_secret(&payload, secret).ok()?;
    if !super::constant_time_equal(supplied_signature.as_bytes(), expected.as_bytes()) {
        return None;
    }
    let mut fields = payload.splitn(4, ':');
    let expires_at: u64 = fields.next()?.parse().ok()?;
    if unix_time().ok()? > expires_at {
        return None;
    }
    let _sub = fields.next().filter(|v| !v.is_empty())?;
    let email = fields.next().filter(|v| !v.is_empty())?.to_string();
    let role = fields.next()?;
    Some((email, role.to_string(), role == "admin"))
}

/// Resolve the caller from `Authorization` (Bearer controller token, Bearer PAT,
/// Basic user:PAT as git sends it) or the session cookie.
pub(super) fn identify(headers: &HeaderMap, state: &AppState) -> Result<Identity, ApiError> {
    let authorization = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .map(str::trim);
    if let Some(value) = authorization {
        let token = if let Some(bearer) = value.strip_prefix("Bearer ") {
            Some(bearer.trim().to_string())
        } else if let Some(basic) = value.strip_prefix("Basic ") {
            let decoded = base64_decode(basic.trim()).ok_or_else(ApiError::unauthorized)?;
            let text = String::from_utf8(decoded).map_err(|_| ApiError::unauthorized())?;
            // git sends user:password; the password is the token (the user is free text).
            Some(
                text.rsplit_once(':')
                    .map_or(text.clone(), |(_, p)| p.to_string()),
            )
        } else {
            None
        };
        let Some(token) = token else {
            return Err(ApiError::unauthorized());
        };
        if super::constant_time_equal(token.as_bytes(), state.token.as_bytes()) {
            return Ok(Identity::Master);
        }
        let now = unix_time()? as i64;
        return match access::authenticate(&state.database, &token, now)
            .map_err(ApiError::internal)?
        {
            Some(principal) => Ok(Identity::Token(principal)),
            None => Err(ApiError::unauthorized()),
        };
    }
    match request_cookie(headers, "ab_session").and_then(|c| session_fields(c, &state.token)) {
        Some((email, _role, admin)) => Ok(Identity::Session { email, admin }),
        None => Err(ApiError::unauthorized()),
    }
}

/// The role the caller holds on `repository`: everything for the controller
/// token and signed-in forge admins; a signed-in member's collaborator role;
/// a token's effective role.
pub(super) fn role_on(
    identity: &Identity,
    state: &AppState,
    repository: &str,
) -> Result<Option<Role>, ApiError> {
    match identity {
        Identity::Master | Identity::Session { admin: true, .. } => Ok(Some(Role::Admin)),
        Identity::Session { email, .. } => {
            access::collaborator_role(&state.database, repository, email).map_err(ApiError::bad)
        }
        Identity::Token(principal) => principal
            .role_on(&state.database, repository)
            .map_err(ApiError::bad),
    }
}

fn require(
    identity: &Identity,
    state: &AppState,
    repository: &str,
    role: Role,
) -> Result<(), ApiError> {
    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}"))),
    }
}

/// Git over Smart HTTP: reads need `read`, `git-receive-pack` needs `write`.
/// Returns the subject for `REMOTE_USER`.
pub(super) fn authorize_git(
    headers: &HeaderMap,
    state: &AppState,
    repository: &str,
    write: bool,
) -> Result<String, ApiError> {
    let identity = identify(headers, state)?;
    let needed = if write { Role::Write } else { Role::Read };
    require(&identity, state, repository, needed)?;
    Ok(identity.subject())
}

/// Whether a Smart HTTP request is a push (`git-receive-pack`).
pub(super) fn is_push(path: &str, query: Option<&str>) -> bool {
    path.ends_with("/git-receive-pack")
        || query.is_some_and(|q| q.split('&').any(|kv| kv == "service=git-receive-pack"))
}

// ---- collaborators -------------------------------------------------------------------

#[derive(Deserialize)]
pub(super) struct CollaboratorRequest {
    subject: String,
    role: String,
}

pub(super) async fn list_collaborators(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<Collaborator>>>, ApiError> {
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Read)?;
    let database = state.database.clone();
    let list = tokio::task::spawn_blocking(move || access::collaborators(&database, &repository))
        .await
        .map_err(ApiError::internal)?
        .map_err(ApiError::bad)?;
    Ok(ok(list))
}

pub(super) async fn put_collaborator(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(request): Json<CollaboratorRequest>,
) -> Result<Json<Envelope<Collaborator>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Admin)?;
    let role = Role::parse(&request.role).map_err(ApiError::bad)?;
    let database = state.database.clone();
    let subject = request.subject.trim().to_lowercase();
    let stored = tokio::task::spawn_blocking(move || -> anyhow::Result<Collaborator> {
        access::set_collaborator(&database, &repository, &subject, role)?;
        access::collaborators(&database, &repository)?
            .into_iter()
            .find(|c| c.subject == subject)
            .ok_or_else(|| anyhow::anyhow!("collaborator was not stored"))
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(stored))
}

pub(super) async fn delete_collaborator(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, subject)): Path<(String, String)>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Admin)?;
    let database = state.database.clone();
    let removed = tokio::task::spawn_blocking(move || {
        access::remove_collaborator(&database, &repository, &subject)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(removed))
}

// ---- tokens and deploy keys -----------------------------------------------------------

#[derive(Deserialize)]
pub(super) struct TokenRequest {
    name: String,
    /// `read`, `write` or `admin` (deploy keys: never `admin`).
    role: String,
    /// PATs only: limit the token to one repository.
    #[serde(default)]
    repository: Option<String>,
    /// Lifetime in seconds (default and maximum one year).
    #[serde(default)]
    ttl_secs: Option<i64>,
    /// Controller token only: whom the PAT is for.
    #[serde(default)]
    subject: Option<String>,
}

#[derive(Serialize)]
pub(super) struct Minted {
    /// Shown once; only its hash is stored.
    pub token: String,
    pub info: TokenInfo,
}

#[derive(Deserialize)]
pub(super) struct TokenQuery {
    #[serde(default)]
    subject: Option<String>,
}

/// Same origin check as other mutations, for sessions; tokens have no origin.
fn authorize_mutation_any(headers: &HeaderMap, state: &AppState) -> Result<(), ApiError> {
    if headers.get(header::AUTHORIZATION).is_some() {
        return Ok(());
    }
    authorize_mutation(headers, state)
}

pub(super) async fn create_pat(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(request): Json<TokenRequest>,
) -> Result<Json<Envelope<Minted>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let subject = match identify(&headers, &state)? {
        Identity::Master => request
            .subject
            .clone()
            .filter(|s| !s.trim().is_empty())
            .ok_or_else(|| ApiError::bad("subject is required with the controller token"))?,
        Identity::Session { email, .. } => email,
        Identity::Token(_) => {
            return Err(ApiError::forbidden("tokens cannot mint tokens; sign in"));
        }
    };
    let role = Role::parse(&request.role).map_err(ApiError::bad)?;
    let database = state.database.clone();
    let now = unix_time()? as i64;
    let minted = tokio::task::spawn_blocking(move || {
        access::create_token(
            &database,
            &NewToken {
                kind: Kind::Pat,
                name: request.name.trim(),
                subject: Some(subject.trim()),
                repository: request.repository.as_deref(),
                role,
                ttl_secs: request.ttl_secs,
            },
            now,
        )
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(Minted {
        token: minted.1,
        info: minted.0,
    }))
}

pub(super) async fn list_pats(
    State(state): State<AppState>,
    headers: HeaderMap,
    Query(query): Query<TokenQuery>,
) -> Result<Json<Envelope<Vec<TokenInfo>>>, ApiError> {
    let subject = match identify(&headers, &state)? {
        Identity::Master => query.subject.clone(),
        Identity::Session { email, .. } => Some(email),
        Identity::Token(p) => p.subject.clone(),
    };
    let database = state.database.clone();
    let list = tokio::task::spawn_blocking(move || {
        access::list_tokens(&database, subject.as_deref(), None, Kind::Pat)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(list))
}

pub(super) async fn revoke_pat(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<i64>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let identity = identify(&headers, &state)?;
    let database = state.database.clone();
    let now = unix_time()? as i64;
    let revoked = tokio::task::spawn_blocking(move || -> anyhow::Result<bool> {
        if let Identity::Session {
            email,
            admin: false,
        } = &identity
        {
            let mine = access::list_tokens(&database, Some(email), None, Kind::Pat)?
                .iter()
                .any(|t| t.id == id);
            if !mine {
                return Ok(false);
            }
        }
        if let Identity::Token(_) = &identity {
            return Ok(false);
        }
        access::revoke_token(&database, id, None, now)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(revoked))
}

pub(super) async fn create_deploy_key(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(request): Json<TokenRequest>,
) -> Result<Json<Envelope<Minted>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Admin)?;
    let role = Role::parse(&request.role).map_err(ApiError::bad)?;
    let database = state.database.clone();
    let now = unix_time()? as i64;
    let minted = tokio::task::spawn_blocking(move || {
        access::create_token(
            &database,
            &NewToken {
                kind: Kind::DeployKey,
                name: request.name.trim(),
                subject: None,
                repository: Some(&repository),
                role,
                ttl_secs: request.ttl_secs,
            },
            now,
        )
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(Minted {
        token: minted.1,
        info: minted.0,
    }))
}

pub(super) async fn list_deploy_keys(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<TokenInfo>>>, ApiError> {
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Admin)?;
    let database = state.database.clone();
    let list = tokio::task::spawn_blocking(move || {
        access::list_tokens(&database, None, Some(&repository), Kind::DeployKey)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(list))
}

pub(super) async fn revoke_deploy_key(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, id)): Path<(String, i64)>,
) -> Result<Json<Envelope<bool>>, ApiError> {
    authorize_mutation_any(&headers, &state)?;
    let identity = identify(&headers, &state)?;
    require(&identity, &state, &repository, Role::Admin)?;
    let database = state.database.clone();
    let now = unix_time()? as i64;
    let revoked = tokio::task::spawn_blocking(move || {
        access::revoke_token(&database, id, Some(&repository), now)
    })
    .await
    .map_err(ApiError::internal)?
    .map_err(ApiError::bad)?;
    Ok(ok(revoked))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        body::{Body, to_bytes},
        http::{Request, StatusCode},
        routing::{delete, get},
    };
    use tower::ServiceExt;

    fn state() -> AppState {
        let state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        state
            .database
            .add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        state
            .database
            .add_repository("other", "https://example.com/other.git", "main")
            .expect("repo");
        state
    }

    fn app(state: AppState) -> Router {
        Router::new()
            .route(
                "/api/repos/{repository}/collaborators",
                get(list_collaborators).put(put_collaborator),
            )
            .route(
                "/api/repos/{repository}/collaborators/{subject}",
                delete(delete_collaborator),
            )
            .route("/api/tokens", get(list_pats).post(create_pat))
            .route("/api/tokens/{id}", delete(revoke_pat))
            .route(
                "/api/repos/{repository}/deploy-keys",
                get(list_deploy_keys).post(create_deploy_key),
            )
            .route(
                "/api/repos/{repository}/deploy-keys/{id}",
                delete(revoke_deploy_key),
            )
            .with_state(state)
    }

    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),
        )
    }

    fn headers(auth: &str) -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert(header::AUTHORIZATION, auth.parse().expect("header"));
        h
    }

    #[test]
    fn base64_decodes_with_and_without_padding() {
        assert_eq!(base64_decode("Zm9vOmJhcg==").expect("ok"), b"foo:bar");
        assert_eq!(base64_decode("Zm9vOmJhcg").expect("ok"), b"foo:bar");
        assert!(base64_decode("***").is_none());
    }

    #[test]
    fn push_detection_reads_the_service_from_path_or_query() {
        assert!(is_push("demo.git/git-receive-pack", None));
        assert!(is_push(
            "demo.git/info/refs",
            Some("service=git-receive-pack")
        ));
        assert!(!is_push(
            "demo.git/info/refs",
            Some("service=git-upload-pack")
        ));
        assert!(!is_push("demo.git/git-upload-pack", None));
    }

    #[tokio::test]
    async fn collaborators_pats_and_deploy_keys_gate_git_by_role() {
        let state = state();
        let app = app(state.clone());
        let master = Some("Bearer api-token-123");

        // controller token manages collaborators
        let (status, body) = send(
            &app,
            "PUT",
            "/api/repos/demo/collaborators",
            master,
            serde_json::json!({"subject": "Ada@Example.is", "role": "write"}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{body}");
        assert_eq!(body["data"]["subject"], "ada@example.is");
        let (_, list) = send(
            &app,
            "GET",
            "/api/repos/demo/collaborators",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(list["data"][0]["role"], "write");

        // a PAT for ada: capped by her collaborator role; none on other repos
        let (status, minted) = send(
            &app,
            "POST",
            "/api/tokens",
            master,
            serde_json::json!({"name": "laptop", "role": "admin", "subject": "ada@example.is"}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{minted}");
        let pat = minted["data"]["token"].as_str().expect("token").to_string();
        assert!(pat.starts_with("abt_"));
        let bearer = format!("Bearer {pat}");
        assert_eq!(
            authorize_git(&headers(&bearer), &state, "demo", false)
                .map_err(|e| e.message)
                .expect("read"),
            "ada@example.is"
        );
        assert_eq!(
            authorize_git(&headers(&bearer), &state, "demo", true)
                .map_err(|e| e.message)
                .expect("write"),
            "ada@example.is"
        );
        assert!(
            authorize_git(&headers(&bearer), &state, "other", false).is_err(),
            "no collaborator role on other"
        );
        // git's Basic user:token form works too
        let basic = format!(
            "Basic {}",
            base64_encode(format!("x-access-token:{pat}").as_bytes())
        );
        assert!(authorize_git(&headers(&basic), &state, "demo", true).is_ok());
        // admin API needs admin role: ada has write
        let (status, _) = send(
            &app,
            "PUT",
            "/api/repos/demo/collaborators",
            Some(&bearer),
            serde_json::json!({"subject": "bob@example.is", "role": "read"}),
        )
        .await;
        assert_eq!(status, StatusCode::FORBIDDEN);
        // ada lists only her own tokens and cannot mint with a token
        let (_, mine) = send(
            &app,
            "GET",
            "/api/tokens",
            Some(&bearer),
            serde_json::json!(null),
        )
        .await;
        assert_eq!(mine["data"].as_array().expect("array").len(), 1);
        let (status, _) = send(
            &app,
            "POST",
            "/api/tokens",
            Some(&bearer),
            serde_json::json!({"name": "x", "role": "read"}),
        )
        .await;
        assert_eq!(status, StatusCode::FORBIDDEN);

        // a read-only deploy key on demo: can fetch, cannot push, nothing elsewhere
        let (status, key) = send(
            &app,
            "POST",
            "/api/repos/demo/deploy-keys",
            master,
            serde_json::json!({"name": "ci", "role": "read"}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{key}");
        let key_bearer = format!("Bearer {}", key["data"]["token"].as_str().expect("token"));
        assert_eq!(
            authorize_git(&headers(&key_bearer), &state, "demo", false)
                .map_err(|e| e.message)
                .expect("read"),
            format!("deploy-key-{}", key["data"]["info"]["id"])
        );
        assert!(authorize_git(&headers(&key_bearer), &state, "demo", true).is_err());
        assert!(authorize_git(&headers(&key_bearer), &state, "other", false).is_err());
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/deploy-keys",
            master,
            serde_json::json!({"name": "bad", "role": "admin"}),
        )
        .await;
        assert_eq!(
            status,
            StatusCode::BAD_REQUEST,
            "deploy keys are never admin"
        );
        let (_, keys) = send(
            &app,
            "GET",
            "/api/repos/demo/deploy-keys",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(keys["data"].as_array().expect("array").len(), 1);
        let id = keys["data"][0]["id"].as_i64().expect("id");
        let (_, revoked) = send(
            &app,
            "DELETE",
            &format!("/api/repos/demo/deploy-keys/{id}"),
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(revoked["data"], true);
        assert!(
            authorize_git(&headers(&key_bearer), &state, "demo", false).is_err(),
            "revoked"
        );

        // removing the collaborator removes ada's access; unknown tokens are 401
        let (_, removed) = send(
            &app,
            "DELETE",
            "/api/repos/demo/collaborators/ada@example.is",
            master,
            serde_json::json!(null),
        )
        .await;
        assert_eq!(removed["data"], true);
        assert!(authorize_git(&headers(&bearer), &state, "demo", false).is_err());
        assert!(authorize_git(&headers("Bearer abt_nope"), &state, "demo", false).is_err());
        assert!(authorize_git(&HeaderMap::new(), &state, "demo", false).is_err());
        assert_eq!(
            authorize_git(&headers("Bearer api-token-123"), &state, "demo", true)
                .map_err(|e| e.message)
                .expect("master"),
            "forge"
        );
    }

    fn base64_encode(bytes: &[u8]) -> String {
        const TABLE: &[u8; 64] =
            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        let mut out = String::new();
        for chunk in bytes.chunks(3) {
            let mut buffer = [0u8; 3];
            buffer[..chunk.len()].copy_from_slice(chunk);
            let n = u32::from(buffer[0]) << 16 | u32::from(buffer[1]) << 8 | u32::from(buffer[2]);
            for i in 0..4 {
                if i <= chunk.len() {
                    out.push(TABLE[((n >> (18 - 6 * i)) & 63) as usize] as char);
                } else {
                    out.push('=');
                }
            }
        }
        out
    }
}