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