diff --git a/README.md b/README.md index 94fdb71..f33a606 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,31 @@ The response body is the GitHub installation token: ghs_... ``` +To request one installation token scoped to several repositories owned by the +same account, omit the repository from the URL and provide repository names in +the JSON request body: + +```sh +curl -X POST \ + -H "Authorization: Bearer $KUBERNETES_JWT" \ + -H "Content-Type: application/json" \ + -d '{ + "repositories": ["repo_one", "repo_two"], + "permissions": { + "contents": "read", + "pull_requests": "write" + } + }' \ + http://localhost:8080/installation-token/deployments/github_user +``` + +Every repository must be authorized by the caller's role and installation +policies. The requested permissions must be allowed for every repository. When +`permissions` is omitted, idcat uses the shared permission policy; if the +repositories have different restricted policies, the request must specify an +explicit common down-scope. The response is a JSON document containing the +token, expiry, permissions, and GitHub repository selection metadata. + To proxy a repository-scoped GitHub API request through an installation token, prefix the GitHub `/repos/{owner}/{repo}` path with `/proxy/{github-app}`. The GitHub app name selects the configured GitHub App and its allowed roles, while the owner/repo pair comes from the proxied diff --git a/src/error.rs b/src/error.rs index 57e335d..40ce704 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,8 @@ use tracing::{error, warn}; #[derive(Debug, thiserror::Error)] pub enum AppError { + #[error("{0}")] + BadRequest(String), #[error("{0}")] NotFound(String), #[error("{0}")] @@ -26,6 +28,7 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { let message = self.to_string(); let status = match &self { + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::NotFound(_) => StatusCode::NOT_FOUND, AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -34,7 +37,7 @@ impl IntoResponse for AppError { AppError::Internal(_) => { error!(status = status.as_u16(), error = %message, "request failed"); } - AppError::NotFound(_) | AppError::Unauthorized(_) => { + AppError::BadRequest(_) | AppError::NotFound(_) | AppError::Unauthorized(_) => { warn!(status = status.as_u16(), error = %message, "request rejected"); } } diff --git a/src/github.rs b/src/github.rs index 5ed8bd4..1d9c6ef 100644 --- a/src/github.rs +++ b/src/github.rs @@ -37,8 +37,8 @@ struct GithubCache { #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct InstallationTokenCacheKey { github_app: String, - repo: String, - scope: crate::service::TokenScope, + repositories: Option>, + permissions: BTreeMap, } #[derive(Clone)] @@ -56,20 +56,23 @@ struct CreateInstallationTokenRequest { } fn build_create_installation_token_request( - scope: &crate::service::TokenScope, - repo: &str, + repositories: Option<&[String]>, + permissions: &BTreeMap, ) -> CreateInstallationTokenRequest { - use crate::service::RepoScope; - let repositories = match scope.repositories { - RepoScope::All => None, - RepoScope::OnlyRequested => { - let repo_name = repo.split_once('/').map(|(_, name)| name).unwrap_or(repo); - Some(vec![repo_name.to_string()]) - } - }; + let repositories = repositories.map(|repositories| { + repositories + .iter() + .map(|repo| { + repo.split_once('/') + .map(|(_, name)| name) + .unwrap_or(repo) + .to_string() + }) + .collect() + }); CreateInstallationTokenRequest { repositories, - permissions: (!scope.permissions.is_empty()).then(|| scope.permissions.clone()), + permissions: (!permissions.is_empty()).then(|| permissions.clone()), } } @@ -117,16 +120,62 @@ impl GithubClient { signer: &dyn Signer, repo: &str, scope: crate::service::TokenScope, + ) -> anyhow::Result { + use crate::service::RepoScope; + let repositories = match scope.repositories { + RepoScope::All => None, + RepoScope::OnlyRequested => Some(vec![repo.to_string()]), + }; + self.create_installation_token_for_repositories( + github_app, + signer, + repo, + repositories, + scope.permissions, + ) + .await + } + + pub async fn create_installation_token_for_multiple_repositories( + &self, + github_app: &GithubAppConfig, + signer: &dyn Signer, + mut repositories: Vec, + permissions: BTreeMap, + ) -> anyhow::Result { + repositories.sort(); + let repo = repositories + .first() + .context("at least one repository is required")? + .clone(); + self.create_installation_token_for_repositories( + github_app, + signer, + &repo, + Some(repositories), + permissions, + ) + .await + } + + async fn create_installation_token_for_repositories( + &self, + github_app: &GithubAppConfig, + signer: &dyn Signer, + repo: &str, + repositories: Option>, + permissions: BTreeMap, ) -> anyhow::Result { let token_cache_key = InstallationTokenCacheKey { github_app: github_app.name.clone(), - repo: repo.to_string(), - scope, + repositories, + permissions, }; if let Some(token) = self.cached_installation_token(&token_cache_key).await { debug!( github_app = %github_app.name, repo = %repo, + repositories = ?token_cache_key.repositories, "using cached GitHub installation access token" ); return Ok(token); @@ -134,6 +183,7 @@ impl GithubClient { debug!( github_app = %github_app.name, repo = %repo, + repositories = ?token_cache_key.repositories, "cached GitHub installation access token not found or expired" ); debug!( @@ -157,7 +207,10 @@ impl GithubClient { installation_id, "resolved GitHub App installation id" ); - let request = build_create_installation_token_request(&token_cache_key.scope, repo); + let request = build_create_installation_token_request( + token_cache_key.repositories.as_deref(), + &token_cache_key.permissions, + ); debug!( github_app = %github_app.name, repo = %repo, @@ -529,28 +582,62 @@ mod tests { #[test] fn build_create_installation_token_request_broad_omits_repositories() { - let request = build_create_installation_token_request(&broad_scope(), "myorg/alfa"); + let request = build_create_installation_token_request(None, &BTreeMap::new()); let json = serde_json::to_value(&request).unwrap(); assert_eq!(json, serde_json::json!({})); } #[test] fn build_create_installation_token_request_narrow_sets_repository_by_name() { - let request = build_create_installation_token_request(&narrow_scope(&[]), "myorg/alfa"); + let repositories = vec!["myorg/alfa".to_string()]; + let request = + build_create_installation_token_request(Some(&repositories), &BTreeMap::new()); let json = serde_json::to_value(&request).unwrap(); assert_eq!(json, serde_json::json!({ "repositories": ["alfa"] })); } #[test] fn build_create_installation_token_request_narrow_includes_permissions() { - let request = build_create_installation_token_request( - &narrow_scope(&[("contents", "read")]), - "myorg/alfa", - ); + let repositories = vec!["myorg/alfa".to_string(), "myorg/bravo".to_string()]; + let permissions = BTreeMap::from([("contents".to_string(), "read".to_string())]); + let request = build_create_installation_token_request(Some(&repositories), &permissions); let json = serde_json::to_value(&request).unwrap(); assert_eq!( json, - serde_json::json!({ "repositories": ["alfa"], "permissions": { "contents": "read" } }) + serde_json::json!({ + "repositories": ["alfa", "bravo"], + "permissions": { "contents": "read" } + }) ); } + + #[tokio::test] + async fn repository_order_does_not_change_multiple_repository_cache_key() { + let (api_url, mint_count) = spawn_stub_github_api().await; + let mut client = GithubClient::new().unwrap(); + client.api_url = api_url; + let github_app = test_github_app(); + let permissions = BTreeMap::from([("contents".to_string(), "read".to_string())]); + + client + .create_installation_token_for_multiple_repositories( + &github_app, + &StubSigner, + vec!["myorg/alfa".to_string(), "myorg/bravo".to_string()], + permissions.clone(), + ) + .await + .unwrap(); + client + .create_installation_token_for_multiple_repositories( + &github_app, + &StubSigner, + vec!["myorg/bravo".to_string(), "myorg/alfa".to_string()], + permissions, + ) + .await + .unwrap(); + + assert_eq!(mint_count.load(Ordering::SeqCst), 1); + } } diff --git a/src/main.rs b/src/main.rs index e991c0e..bdbbaa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,9 @@ use axum::response::Response; use axum::routing::{any, get, post}; use axum::{Json, Router}; use clap::Parser; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddr; use tower_http::trace::{self, TraceLayer}; use tracing::Level; @@ -94,6 +96,10 @@ async fn run(cli: Cli) -> anyhow::Result<()> { "/installation-token/{github_app}/{owner}/{repo}", post(installation_token), ) + .route( + "/installation-token/{github_app}/{owner}", + post(installation_token_for_repositories), + ) .route( "/proxy/{github_app}/repos/{owner}/{repo}", any(proxy_repo_root), @@ -207,6 +213,156 @@ async fn installation_token( Ok(token.token) } +const MAX_TOKEN_REPOSITORIES: usize = 500; + +#[derive(Debug, Deserialize)] +struct MultipleRepositoriesTokenRequest { + repositories: Vec, + permissions: Option>, +} + +#[derive(Debug, Serialize)] +struct MultipleRepositoriesTokenResponse { + #[serde(flatten)] + installation_token: InstallationTokenResponse, + repositories: Vec, +} + +async fn installation_token_for_repositories( + Path((github_app_name, owner)): Path<(String, String)>, + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, AppError> { + validate_repository_names(&request.repositories)?; + let bearer_token = request_bearer_token(&state, &headers)?; + let github_app = state.github_app(&github_app_name)?; + let repositories: Vec = request + .repositories + .iter() + .map(|repo| format!("{owner}/{repo}")) + .collect(); + let scopes = repositories + .iter() + .map(|repo| state.authorize_github_app(github_app, repo, bearer_token.as_deref())) + .collect::, _>>()?; + let permissions = select_multiple_repository_permissions(request.permissions, &scopes)?; + let signer = state.signer(&github_app.secret_key)?; + let token = state + .github + .create_installation_token_for_multiple_repositories( + github_app, + signer.as_ref(), + repositories, + permissions, + ) + .await?; + Ok(Json(MultipleRepositoriesTokenResponse { + installation_token: token, + repositories: request + .repositories + .into_iter() + .map(|repo| format!("{owner}/{repo}")) + .collect(), + })) +} + +fn validate_repository_names(repositories: &[String]) -> Result<(), AppError> { + if repositories.is_empty() { + return Err(AppError::BadRequest( + "repositories must contain at least one repository".to_string(), + )); + } + if repositories.len() > MAX_TOKEN_REPOSITORIES { + return Err(AppError::BadRequest(format!( + "repositories must contain no more than {MAX_TOKEN_REPOSITORIES} repositories" + ))); + } + let mut unique = BTreeSet::new(); + for repository in repositories { + if repository.is_empty() || repository.contains('/') { + return Err(AppError::BadRequest(format!( + "repository '{repository}' must be a repository name without an owner" + ))); + } + if !unique.insert(repository) { + return Err(AppError::BadRequest(format!( + "repository '{repository}' is listed more than once" + ))); + } + } + Ok(()) +} + +fn select_multiple_repository_permissions( + requested: Option>, + scopes: &[crate::service::TokenScope], +) -> Result, AppError> { + if let Some(requested) = requested { + if requested.is_empty() { + return Err(AppError::BadRequest( + "permissions must not be empty when specified".to_string(), + )); + } + for scope in scopes { + ensure_permissions_allowed(&requested, &scope.permissions)?; + } + return Ok(requested); + } + + let mut restricted = scopes + .iter() + .map(|scope| &scope.permissions) + .filter(|permissions| !permissions.is_empty()); + let Some(first) = restricted.next() else { + return Ok(BTreeMap::new()); + }; + if restricted.any(|permissions| permissions != first) { + return Err(AppError::BadRequest( + "repositories have different permission policies; specify permissions explicitly" + .to_string(), + )); + } + Ok(first.clone()) +} + +fn ensure_permissions_allowed( + requested: &BTreeMap, + allowed: &BTreeMap, +) -> Result<(), AppError> { + if allowed.is_empty() { + return Ok(()); + } + for (permission, requested_level) in requested { + let Some(allowed_level) = allowed.get(permission) else { + return Err(AppError::Unauthorized(format!( + "permission '{permission}' is not allowed by every matching installation policy" + ))); + }; + if !permission_level_is_at_most(requested_level, allowed_level) { + return Err(AppError::Unauthorized(format!( + "permission '{permission}' level '{requested_level}' exceeds allowed level '{allowed_level}'" + ))); + } + } + Ok(()) +} + +fn permission_level_is_at_most(requested: &str, allowed: &str) -> bool { + fn rank(level: &str) -> Option { + match level { + "read" => Some(1), + "write" => Some(2), + "admin" => Some(3), + _ => None, + } + } + match (rank(requested), rank(allowed)) { + (Some(requested), Some(allowed)) => requested <= allowed, + _ => requested == allowed, + } +} + async fn proxy_repo_root( Path((github_app, owner, repo)): Path<(String, String, String)>, State(state): State, @@ -332,22 +488,7 @@ async fn create_installation_token_for_repo( headers: &HeaderMap, ) -> Result { debug!(github_app = %github_app_name, repo = %repo, "installation token flow started"); - let bearer_token = match extract_bearer_token(headers) { - Ok(token) => { - debug!(github_app = %github_app_name, repo = %repo, "authorization bearer token found"); - Some(token) - } - Err(error) if !state.token_validator.auth_enabled() => { - debug!( - github_app = %github_app_name, - repo = %repo, - error = %error, - "authorization bearer token missing or invalid; continuing because auth is disabled" - ); - None - } - Err(error) => return Err(error), - }; + let bearer_token = request_bearer_token(state, headers)?; debug!(github_app = %github_app_name, repo = %repo, "selecting GitHub App config"); let github_app = state.github_app(github_app_name)?; let token_scope = state.authorize_github_app(github_app, repo, bearer_token.as_deref())?; @@ -362,6 +503,14 @@ async fn create_installation_token_for_repo( Ok(token) } +fn request_bearer_token(state: &AppState, headers: &HeaderMap) -> Result, AppError> { + match extract_bearer_token(headers) { + Ok(token) => Ok(Some(token)), + Err(_) if !state.token_validator.auth_enabled() => Ok(None), + Err(error) => Err(error), + } +} + fn extract_bearer_token(headers: &HeaderMap) -> Result { let value = headers .get(header::AUTHORIZATION) @@ -393,15 +542,19 @@ fn should_return_proxy_header(name: &HeaderName) -> bool { #[cfg(test)] mod tests { - use super::webhook; + use super::{ + permission_level_is_at_most, select_multiple_repository_permissions, + validate_repository_names, webhook, + }; use crate::config::{GithubAppConfig, KeySource}; use crate::error::AppError; use crate::github::GithubClient; use crate::secret::FilePrivateKeyStore; - use crate::service::{AppState, TokenValidator}; + use crate::service::{AppState, RepoScope, TokenScope, TokenValidator}; use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; + use std::collections::BTreeMap; use std::sync::Arc; fn test_state(github_apps: Vec) -> AppState { @@ -506,4 +659,82 @@ mod tests { std::fs::remove_file(&secret_file).ok(); assert_eq!(status, StatusCode::ACCEPTED); } + + fn token_scope(permissions: &[(&str, &str)]) -> TokenScope { + TokenScope { + repositories: RepoScope::OnlyRequested, + permissions: permissions + .iter() + .map(|(name, level)| (name.to_string(), level.to_string())) + .collect(), + } + } + + #[test] + fn validates_multiple_repository_names() { + validate_repository_names(&["alfa".to_string(), "bravo".to_string()]).unwrap(); + + assert!(validate_repository_names(&[]).is_err()); + assert!(validate_repository_names(&["myorg/alfa".to_string()]).is_err()); + assert!(validate_repository_names(&["alfa".to_string(), "alfa".to_string()]).is_err()); + } + + #[test] + fn explicit_permissions_must_be_allowed_for_every_repository() { + let requested = BTreeMap::from([("contents".to_string(), "write".to_string())]); + let scopes = vec![ + token_scope(&[("contents", "write")]), + token_scope(&[("contents", "read")]), + ]; + + assert!(select_multiple_repository_permissions(Some(requested), &scopes).is_err()); + } + + #[test] + fn explicit_permissions_can_downscope_every_repository() { + let requested = BTreeMap::from([("contents".to_string(), "read".to_string())]); + let scopes = vec![ + token_scope(&[("contents", "write")]), + token_scope(&[("contents", "read")]), + ]; + + assert_eq!( + select_multiple_repository_permissions(Some(requested.clone()), &scopes).unwrap(), + requested + ); + } + + #[test] + fn omitted_permissions_use_shared_restricted_policy() { + let expected = BTreeMap::from([("contents".to_string(), "read".to_string())]); + let scopes = vec![ + token_scope(&[]), + token_scope(&[("contents", "read")]), + token_scope(&[("contents", "read")]), + ]; + + assert_eq!( + select_multiple_repository_permissions(None, &scopes).unwrap(), + expected + ); + } + + #[test] + fn omitted_permissions_reject_different_restricted_policies() { + let scopes = vec![ + token_scope(&[("contents", "read")]), + token_scope(&[("issues", "write")]), + ]; + + assert!(select_multiple_repository_permissions(None, &scopes).is_err()); + } + + #[test] + fn permission_levels_are_ordered() { + assert!(permission_level_is_at_most("read", "write")); + assert!(permission_level_is_at_most("write", "admin")); + assert!(!permission_level_is_at_most("write", "read")); + assert!(permission_level_is_at_most("custom", "custom")); + assert!(!permission_level_is_at_most("custom", "read")); + } }