diff --git a/.env.example b/.env.example index 2a9cd02..ce0b19f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,17 @@ DATABASE_URL=postgres://echidnabot:changeme@postgres:5432/echidnabot # mapping in compose.yml if you need something else. ECHIDNABOT__SERVER__HOST=0.0.0.0 ECHIDNABOT__SERVER__PORT=8080 + +# GraphQL API credentials. REQUIRED here: the container binds 0.0.0.0, and +# the daemon refuses a non-loopback bind without at least one of them. +# Each at least 32 bytes, the two different: `openssl rand -hex 32`. +# ECHIDNABOT_API_ROUTE_TOKEN submitProofObligation only, for [api] route_repos +# (the credential Hypatia holds) +# ECHIDNABOT_API_ADMIN_TOKEN every GraphQL query and mutation +# ECHIDNABOT_API_ROUTE_TOKEN= +# ECHIDNABOT_API_ADMIN_TOKEN= +# The repositories the route credential may submit for are set in +# echidnabot.toml: [api] route_repos = ["hyperpolymath/*"]. RUST_LOG=info # ─── ECHIDNA prover orchestrator (REST endpoint) ──────────────────────── diff --git a/Cargo.lock b/Cargo.lock index 17dc9a4..f6b7e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,9 +923,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", ] @@ -1878,7 +1878,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.11", ] [[package]] @@ -2280,9 +2280,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", @@ -3365,7 +3365,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.11", ] [[package]] @@ -3539,9 +3539,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -5038,14 +5038,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] diff --git a/docs/content/configuration.adoc b/docs/content/configuration.adoc index 95dffc6..d080654 100644 --- a/docs/content/configuration.adoc +++ b/docs/content/configuration.adoc @@ -13,10 +13,15 @@ The main configuration file, located in your repository root or at ---- # Server configuration [server] -host = "0.0.0.0" +host = "127.0.0.1" # a non-loopback host needs API credentials port = 8080 workers = 4 +# GraphQL API access (credentials come from the environment only) +[api] +route_repos = ["hyperpolymath/*"] # "owner/name" or "owner/*" +rate_limit_rpm = 120 # per TCP peer address; 0 disables + # Database configuration [database] # SQLite (development) @@ -66,6 +71,8 @@ webhook_secret = "${GITLAB_WEBHOOK_SECRET}" |`+ECHIDNABOT_ECHIDNA_ENDPOINT+` |ECHIDNA Core GraphQL endpoint |Yes |`+ECHIDNABOT_ECHIDNA_REST_ENDPOINT+` |ECHIDNA Core REST endpoint |No |`+ECHIDNABOT_ECHIDNA_MODE+` |ECHIDNA API mode (auto/graphql/rest) |No +|`+ECHIDNABOT_API_ROUTE_TOKEN+` |Bearer credential for `submitProofObligation` only, limited to `[api] route_repos`; at least 32 bytes |To submit obligations +|`+ECHIDNABOT_API_ADMIN_TOKEN+` |Bearer credential for every GraphQL query and mutation; at least 32 bytes, distinct from the route credential |To use the API |`+GITHUB_WEBHOOK_SECRET+` |GitHub webhook secret |For GitHub |`+GITHUB_APP_ID+` |GitHub App ID |For GitHub |`+GITHUB_PRIVATE_KEY+` |GitHub App private key (PEM) |For GitHub diff --git a/echidnabot.example.toml b/echidnabot.example.toml index 77dbfcc..b94a998 100644 --- a/echidnabot.example.toml +++ b/echidnabot.example.toml @@ -2,11 +2,27 @@ # Copy to echidnabot.toml and customize [server] -# Host to bind to (0.0.0.0 for all interfaces) -host = "0.0.0.0" +# Host to bind to. Loopback by default. Binding every interface +# ("0.0.0.0") needs ECHIDNABOT_API_ROUTE_TOKEN and/or +# ECHIDNABOT_API_ADMIN_TOKEN in the environment, or the daemon refuses to start. +host = "127.0.0.1" # Port to listen on port = 8080 +[api] +# The GraphQL API at /graphql needs a bearer credential. The two credentials +# are read only from the environment, never from this file: +# ECHIDNABOT_API_ROUTE_TOKEN submitProofObligation only, for route_repos +# ECHIDNABOT_API_ADMIN_TOKEN every query and mutation +# Each must be at least 32 bytes and they must differ: `openssl rand -hex 32`. +# Repositories the route credential may submit for: "owner/name" or "owner/*". +# Empty allows none. +route_repos = ["hyperpolymath/*"] +# /graphql requests per minute per TCP peer address (0 disables). The peer is +# the address that connected, so behind a reverse proxy every client shares +# the proxy's budget: limit at the proxy instead and set 0 here. +rate_limit_rpm = 120 + [database] # SQLite for development, PostgreSQL for production # SQLite: sqlite://path/to/db.sqlite diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..a19eef2 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Authentication and authorization for the GraphQL API. +//! +//! Before this module the whole schema was served without authentication. +//! Any caller that could reach the port could rewrite a repository's webhook +//! secret (`updateRepoSettings`) and then forge webhooks, or write training +//! rows (`recordTacticOutcome`). +//! +//! The API now has two credentials, each read from its own environment +//! variable and never from a config file: +//! +//! * the *route* credential ([`ROUTE_TOKEN_ENV`]) may only call +//! `submitProofObligation`, and only for repositories in +//! `[api] route_repos`. It is the credential Hypatia holds; +//! * the *admin* credential ([`ADMIN_TOKEN_ENV`]) may call everything. +//! +//! Enforcement is in two layers. [`api_gate_middleware`] runs before the +//! schema: it rate-limits by peer address and answers 401 to a request with +//! no valid credential. Field guards ([`admin_only`], [`route_or_admin`], +//! [`check_repo_scope`]) run inside the schema and fail closed: a resolver +//! executed without a [`Principal`] in the request data is refused, so a +//! caller that executes the schema directly cannot bypass the gate. + +use std::sync::Arc; + +use async_graphql::{Context, ErrorExtensions}; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; +use axum::{ + body::Body, + extract::{ConnectInfo, State}, + http::{header, Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{post, MethodRouter}, + Extension, +}; +use std::net::SocketAddr; + +use super::graphql::EchidnabotSchema; +use super::rate_limit::WebhookRateLimiter; +use crate::trust::solver_integrity::constant_time_eq; + +/// Environment variable holding the route credential. +pub const ROUTE_TOKEN_ENV: &str = "ECHIDNABOT_API_ROUTE_TOKEN"; + +/// Environment variable holding the administrative credential. +pub const ADMIN_TOKEN_ENV: &str = "ECHIDNABOT_API_ADMIN_TOKEN"; + +/// Shortest credential accepted, in bytes. Generate one with +/// `openssl rand -hex 32` (64 characters). +pub const MIN_TOKEN_LEN: usize = 32; + +/// Who a request is, once its credential has been checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Principal { + /// The route credential: submit obligations for the repositories in scope. + Route(RepoScope), + /// The administrative credential: every query and mutation. + Admin, +} + +/// The repositories the route credential may submit obligations for. +/// +/// Each pattern is either an exact `owner/name` slug or `owner/*`, which +/// matches every repository of that owner. There is no global wildcard: an +/// empty scope allows nothing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RepoScope { + patterns: Vec, +} + +impl RepoScope { + /// Build a scope from `owner/name` and `owner/*` patterns. + pub fn new(patterns: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + patterns: patterns.into_iter().map(Into::into).collect(), + } + } + + /// Whether the scope lists no pattern at all. + pub fn is_empty(&self) -> bool { + self.patterns.is_empty() + } + + /// Whether `repo` (an `owner/name` slug) is in scope. + pub fn allows(&self, repo: &str) -> bool { + let Some((owner, name)) = repo.split_once('/') else { + return false; + }; + if owner.is_empty() || name.is_empty() || name.contains('/') { + return false; + } + self.patterns + .iter() + .any(|pattern| match pattern.split_once('/') { + Some((p_owner, "*")) => !p_owner.is_empty() && p_owner == owner, + Some(_) => pattern == repo, + None => false, + }) + } +} + +/// A credential that cannot be used safely. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum CredentialError { + /// The named credential is shorter than [`MIN_TOKEN_LEN`] bytes. + #[error("{0} is shorter than {MIN_TOKEN_LEN} bytes")] + TooShort(&'static str), + /// Both credentials are the same string, so roles cannot be told apart. + #[error("{ROUTE_TOKEN_ENV} and {ADMIN_TOKEN_ENV} must differ")] + NotDistinct, +} + +/// The configured credentials and the route credential's scope. +#[derive(Clone, Default)] +pub struct ApiCredentials { + route_token: Option, + admin_token: Option, + route_scope: RepoScope, +} + +impl std::fmt::Debug for ApiCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiCredentials") + .field("route_token", &crate::config::redacted(&self.route_token)) + .field("admin_token", &crate::config::redacted(&self.admin_token)) + .field("route_scope", &self.route_scope) + .finish() + } +} + +impl ApiCredentials { + /// Validate and hold the two credentials. Surrounding whitespace is + /// removed, as it is from a presented token, so a value read from a file + /// with a trailing newline still matches. An empty value counts as unset. + /// + /// # Errors + /// [`CredentialError`] when a credential is too short or both are equal. + pub fn new( + route_token: Option, + admin_token: Option, + route_scope: RepoScope, + ) -> Result { + let normalise = + |t: Option| t.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); + let route_token = normalise(route_token); + let admin_token = normalise(admin_token); + if route_token + .as_ref() + .is_some_and(|t| t.len() < MIN_TOKEN_LEN) + { + return Err(CredentialError::TooShort(ROUTE_TOKEN_ENV)); + } + if admin_token + .as_ref() + .is_some_and(|t| t.len() < MIN_TOKEN_LEN) + { + return Err(CredentialError::TooShort(ADMIN_TOKEN_ENV)); + } + if let (Some(route), Some(admin)) = (&route_token, &admin_token) { + if constant_time_eq(route.as_bytes(), admin.as_bytes()) { + return Err(CredentialError::NotDistinct); + } + } + Ok(Self { + route_token, + admin_token, + route_scope, + }) + } + + /// Read both credentials from [`ROUTE_TOKEN_ENV`] and [`ADMIN_TOKEN_ENV`]. + /// + /// # Errors + /// As for [`ApiCredentials::new`]. + pub fn from_env(route_scope: RepoScope) -> Result { + Self::new( + std::env::var(ROUTE_TOKEN_ENV).ok(), + std::env::var(ADMIN_TOKEN_ENV).ok(), + route_scope, + ) + } + + /// Whether no credential is configured, so every API request is refused. + pub fn is_empty(&self) -> bool { + self.route_token.is_none() && self.admin_token.is_none() + } + + /// Whether a route credential is configured. + pub fn has_route_token(&self) -> bool { + self.route_token.is_some() + } + + /// The route credential's repository scope. + pub fn route_scope(&self) -> &RepoScope { + &self.route_scope + } + + /// Map an `Authorization` header value to a principal. + /// + /// Accepts `Bearer ` with the scheme in any letter case. Both + /// credentials are compared in constant time, so the response time does + /// not reveal which one, if either, a guess was close to. + pub fn authenticate(&self, authorization: Option<&str>) -> Option { + let value = authorization?.trim(); + let (scheme, token) = value.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("bearer") { + return None; + } + let token = token.trim().as_bytes(); + if token.is_empty() { + return None; + } + let is_admin = self + .admin_token + .as_ref() + .is_some_and(|t| constant_time_eq(t.as_bytes(), token)); + let is_route = self + .route_token + .as_ref() + .is_some_and(|t| constant_time_eq(t.as_bytes(), token)); + if is_admin { + Some(Principal::Admin) + } else if is_route { + Some(Principal::Route(self.route_scope.clone())) + } else { + None + } + } +} + +/// What the gate needs: the credentials and an optional per-IP limiter. +pub struct ApiGate { + credentials: ApiCredentials, + limiter: Option, +} + +impl ApiGate { + /// A gate over `credentials`, limiting each peer to `rate_limit_rpm` + /// requests a minute when given. + pub fn new(credentials: ApiCredentials, rate_limit_rpm: Option) -> Self { + Self { + credentials, + limiter: rate_limit_rpm.map(WebhookRateLimiter::new), + } + } +} + +/// Middleware in front of `POST /graphql`: rate limit, then authenticate. +/// +/// A request over the limit gets 429. A request without a valid credential +/// gets 401 with `WWW-Authenticate: Bearer` and never reaches the schema. An +/// authenticated request carries its [`Principal`] onward in the request +/// extensions. +pub async fn api_gate_middleware( + State(gate): State>, + mut request: Request, + next: Next, +) -> Response { + if let Some(limiter) = &gate.limiter { + let peer_ip = request + .extensions() + .get::>() + .map(|ci| ci.0.ip()); + if let Some(ip) = peer_ip { + if !limiter.check_ip(ip) { + tracing::warn!(%ip, limit = limiter.limit(), "GraphQL rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, "60")], + "Rate limit exceeded", + ) + .into_response(); + } + } + } + + let authorization = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + match gate.credentials.authenticate(authorization) { + Some(principal) => { + request.extensions_mut().insert(principal); + next.run(request).await + } + None => ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer")], + "A valid bearer credential is required", + ) + .into_response(), + } +} + +/// `POST /graphql`: execute the request with the caller's principal attached. +pub async fn graphql_post( + Extension(schema): Extension, + Extension(principal): Extension, + request: GraphQLRequest, +) -> GraphQLResponse { + schema + .execute(request.into_inner().data(principal)) + .await + .into() +} + +/// The guarded `POST /graphql` handler, ready to mount on any router. +/// +/// The router must also provide `Extension`. +pub fn graphql_post_route(gate: Arc) -> MethodRouter +where + S: Clone + Send + Sync + 'static, +{ + post(graphql_post).layer(middleware::from_fn_with_state(gate, api_gate_middleware)) +} + +fn unauthenticated() -> async_graphql::Error { + async_graphql::Error::new("authentication required") + .extend_with(|_, e| e.set("code", "UNAUTHENTICATED")) +} + +fn forbidden(reason: &str) -> async_graphql::Error { + async_graphql::Error::new(format!("forbidden: {reason}")) + .extend_with(|_, e| e.set("code", "FORBIDDEN")) +} + +/// Field guard: the administrative credential only. +pub fn admin_only(ctx: &Context<'_>) -> async_graphql::Result<()> { + match ctx.data_opt::() { + Some(Principal::Admin) => Ok(()), + Some(Principal::Route(_)) => { + Err(forbidden("this field needs the administrative credential")) + } + None => Err(unauthenticated()), + } +} + +/// Field guard: either credential. +pub fn route_or_admin(ctx: &Context<'_>) -> async_graphql::Result<()> { + match ctx.data_opt::() { + Some(_) => Ok(()), + None => Err(unauthenticated()), + } +} + +/// Refuse `repo` unless the caller is the administrator or `repo` is in the +/// route credential's scope. +pub fn check_repo_scope(ctx: &Context<'_>, repo: &str) -> async_graphql::Result<()> { + match ctx.data_opt::() { + Some(Principal::Admin) => Ok(()), + Some(Principal::Route(scope)) if scope.allows(repo) => Ok(()), + Some(Principal::Route(_)) => Err(forbidden( + "repository is outside the route credential's scope", + )), + None => Err(unauthenticated()), + } +} + +/// Whether `host` binds only the loopback interface. +pub fn is_loopback_host(host: &str) -> bool { + let trimmed = host.trim_start_matches('[').trim_end_matches(']'); + if trimmed.eq_ignore_ascii_case("localhost") { + return true; + } + trimmed + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROUTE: &str = "test-route-credential-0123456789abcdef"; + const ADMIN: &str = "test-admin-credential-0123456789abcdef"; + + fn creds(scope: &[&str]) -> ApiCredentials { + ApiCredentials::new( + Some(ROUTE.to_string()), + Some(ADMIN.to_string()), + RepoScope::new(scope.iter().copied()), + ) + .unwrap() + } + + #[test] + fn scope_matches_exact_and_owner_wildcard_only() { + let scope = RepoScope::new(["hyperpolymath/*", "other/one"]); + assert!(scope.allows("hyperpolymath/hypatia")); + assert!(scope.allows("other/one")); + assert!(!scope.allows("other/two")); + assert!(!scope.allows("hyperpolymathx/hypatia")); + assert!(!scope.allows("hyperpolymath/")); + assert!(!scope.allows("hyperpolymath/a/b")); + assert!(!scope.allows("hyperpolymath")); + assert!(!RepoScope::new(["*"]).allows("a/b")); + assert!(!RepoScope::new(["*/*"]).allows("a/b")); + assert!(!RepoScope::default().allows("a/b")); + } + + #[test] + fn stored_credentials_are_trimmed_like_presented_ones() { + // A value read from a secrets file often ends in a newline. + let c = ApiCredentials::new( + Some(format!(" {ROUTE}\n")), + Some(format!("{ADMIN}\n")), + RepoScope::new(["a/*"]), + ) + .unwrap(); + assert_eq!( + c.authenticate(Some(&format!("Bearer {ROUTE}"))), + Some(Principal::Route(RepoScope::new(["a/*"]))) + ); + assert_eq!( + c.authenticate(Some(&format!("Bearer {ADMIN}"))), + Some(Principal::Admin) + ); + // Whitespace alone is unset, not a short credential. + let blank = ApiCredentials::new(Some(" \n".into()), None, RepoScope::default()).unwrap(); + assert!(blank.is_empty()); + } + + #[test] + fn authenticate_maps_each_credential_to_its_role() { + let c = creds(&["hyperpolymath/*"]); + assert_eq!( + c.authenticate(Some(&format!("Bearer {ADMIN}"))), + Some(Principal::Admin) + ); + assert_eq!( + c.authenticate(Some(&format!("bearer {ROUTE}"))), + Some(Principal::Route(RepoScope::new(["hyperpolymath/*"]))) + ); + } + + #[test] + fn authenticate_refuses_missing_wrong_and_malformed_headers() { + let c = creds(&[]); + assert_eq!(c.authenticate(None), None); + assert_eq!(c.authenticate(Some("")), None); + assert_eq!(c.authenticate(Some("Bearer")), None); + assert_eq!(c.authenticate(Some("Bearer ")), None); + assert_eq!(c.authenticate(Some(&format!("Basic {ADMIN}"))), None); + assert_eq!(c.authenticate(Some(ADMIN)), None); + assert_eq!( + c.authenticate(Some("Bearer not-a-configured-credential-xxxx")), + None + ); + } + + #[test] + fn no_credentials_authenticate_nobody() { + let c = ApiCredentials::default(); + assert!(c.is_empty()); + assert_eq!( + c.authenticate(Some("Bearer anything-at-all-anything-at-all")), + None + ); + } + + #[test] + fn short_or_identical_credentials_are_refused() { + assert_eq!( + ApiCredentials::new(Some("short".into()), None, RepoScope::default()).unwrap_err(), + CredentialError::TooShort(ROUTE_TOKEN_ENV) + ); + assert_eq!( + ApiCredentials::new(None, Some("short".into()), RepoScope::default()).unwrap_err(), + CredentialError::TooShort(ADMIN_TOKEN_ENV) + ); + assert_eq!( + ApiCredentials::new(Some(ADMIN.into()), Some(ADMIN.into()), RepoScope::default()) + .unwrap_err(), + CredentialError::NotDistinct + ); + assert!(ApiCredentials::new( + Some(String::new()), + Some(String::new()), + RepoScope::default() + ) + .unwrap() + .is_empty()); + } + + #[test] + fn debug_never_prints_a_credential() { + let rendered = format!("{:?}", creds(&[])); + assert!(!rendered.contains(ROUTE)); + assert!(!rendered.contains(ADMIN)); + assert!(rendered.contains("")); + } + + #[test] + fn loopback_hosts_are_recognised() { + for host in [ + "127.0.0.1", + "::1", + "[::1]", + "localhost", + "LOCALHOST", + "127.1.2.3", + ] { + assert!(is_loopback_host(host), "{host}"); + } + for host in ["0.0.0.0", "::", "192.168.1.10", "example.org", ""] { + assert!(!is_loopback_host(host), "{host}"); + } + } +} diff --git a/src/api/graphql.rs b/src/api/graphql.rs index 9b6b0a1..9a1391c 100644 --- a/src/api/graphql.rs +++ b/src/api/graphql.rs @@ -4,6 +4,8 @@ //! GraphQL schema and resolvers use async_graphql::{Context, EmptySubscription, Object, Schema, SimpleObject, ID}; + +use super::auth::{admin_only, check_repo_scope, route_or_admin}; use chrono::{DateTime, Utc}; use std::sync::Arc; use uuid::Uuid; @@ -199,6 +201,7 @@ pub struct QueryRoot; #[Object] impl QueryRoot { /// Get a repository by platform, owner, and name + #[graphql(guard = "admin_only")] async fn repository( &self, ctx: &Context<'_>, @@ -216,6 +219,7 @@ impl QueryRoot { } /// List all registered repositories + #[graphql(guard = "admin_only")] async fn repositories(&self, ctx: &Context<'_>, platform: Option) -> Vec { let state = match ctx.data::() { Ok(state) => state, @@ -230,6 +234,7 @@ impl QueryRoot { } /// Get a proof job by ID + #[graphql(guard = "admin_only")] async fn job(&self, ctx: &Context<'_>, id: ID) -> Option { let state = ctx.data::().ok()?; let job_id = Uuid::parse_str(id.as_str()).ok()?; @@ -242,6 +247,7 @@ impl QueryRoot { } /// List jobs for a repository + #[graphql(guard = "admin_only")] async fn jobs_for_repo( &self, ctx: &Context<'_>, @@ -266,6 +272,7 @@ impl QueryRoot { } /// List available provers + #[graphql(guard = "admin_only")] async fn available_provers(&self, ctx: &Context<'_>) -> Vec { let state = match ctx.data::() { Ok(state) => state, @@ -293,6 +300,7 @@ impl QueryRoot { } /// Check prover status + #[graphql(guard = "admin_only")] async fn prover_status(&self, ctx: &Context<'_>, prover: ProverKind) -> ProverStatus { let state = match ctx.data::() { Ok(state) => state, @@ -309,6 +317,7 @@ impl QueryRoot { /// /// Used by LLM agents to inspect historical success rates before suggesting /// a tactic, and by operators to audit the feedback store. + #[graphql(guard = "admin_only")] async fn tactic_outcomes( &self, ctx: &Context<'_>, @@ -337,6 +346,7 @@ impl QueryRoot { /// List recorded tactic outcomes for a specific (prover, tactic) pair /// across all goal fingerprints. Useful for global win-rate queries. + #[graphql(guard = "admin_only")] async fn tactic_outcomes_by_tactic( &self, ctx: &Context<'_>, @@ -424,6 +434,7 @@ pub struct ProofObligationPayload { #[Object] impl MutationRoot { /// Register a repository for monitoring + #[graphql(guard = "admin_only")] async fn register_repository( &self, ctx: &Context<'_>, @@ -446,6 +457,7 @@ impl MutationRoot { } /// Manually trigger a proof check + #[graphql(guard = "admin_only")] async fn trigger_check( &self, ctx: &Context<'_>, @@ -502,6 +514,7 @@ impl MutationRoot { } /// Request ML-powered tactic suggestions + #[graphql(guard = "admin_only")] async fn request_suggestions( &self, ctx: &Context<'_>, @@ -519,6 +532,7 @@ impl MutationRoot { } /// Update repository settings + #[graphql(guard = "admin_only")] async fn update_repo_settings( &self, ctx: &Context<'_>, @@ -561,6 +575,7 @@ impl MutationRoot { } /// Enable or disable repository monitoring + #[graphql(guard = "admin_only")] async fn set_repo_enabled( &self, ctx: &Context<'_>, @@ -592,6 +607,7 @@ impl MutationRoot { /// tactic being applied in a proof session. The outcome feeds the local /// Reranker store so future suggestions for the same goal fingerprint are /// ranked by historical success rate. + #[graphql(guard = "admin_only")] async fn record_tactic_outcome( &self, ctx: &Context<'_>, @@ -628,6 +644,7 @@ impl MutationRoot { /// prover yet, and `inline: true` is recorded but not honoured. Invalid /// input or a store failure is returned as a GraphQL error, because one of /// the two senders reads only `errors` and ignores `success`. + #[graphql(guard = "route_or_admin")] async fn submit_proof_obligation( &self, ctx: &Context<'_>, @@ -644,6 +661,7 @@ impl MutationRoot { ))) } }; + check_repo_scope(ctx, &input.repo)?; if input.claim.trim().is_empty() { return Err(async_graphql::Error::new("claim must not be empty")); } diff --git a/src/api/mod.rs b/src/api/mod.rs index b2b6aa0..9d69e31 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,7 @@ // SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell //! API layer - GraphQL and webhook handlers +pub mod auth; pub mod graphql; pub mod rate_limit; pub mod webhooks; diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs index f787789..e6ef6f1 100644 --- a/src/api/rate_limit.rs +++ b/src/api/rate_limit.rs @@ -11,7 +11,11 @@ //! //! No external crates: uses a `Mutex>>` //! with lock time bounded by the dequeue sweep. Under webhook load (at most -//! a few hundred requests/minute per IP) this is negligible. +//! a few hundred requests/minute per IP) this is negligible. Idle peers are +//! swept out as the map grows, so its size follows recent traffic. +//! +//! The key is the TCP peer address. Forwarding headers are not trusted, so +//! behind a reverse proxy every client shares the proxy's budget. use std::collections::{HashMap, VecDeque}; use std::net::IpAddr; @@ -32,18 +36,41 @@ use super::webhooks::AppState; // ConnectInfo and SocketAddr are used in the middleware body via // request.extensions().get::>(). +/// Map size below which idle peers are never swept. +const SWEEP_FLOOR: usize = 4096; + +/// Per-peer request times, plus the map size at which idle peers are next +/// swept out. +#[derive(Default)] +struct LimiterState { + peers: HashMap>, + sweep_at: usize, +} + /// Sliding-window per-IP rate limiter (60-second window). +/// +/// Memory is bounded by recent traffic, not by every address ever seen: when +/// the map grows past `max(SWEEP_FLOOR, 2 × size after the last sweep)`, +/// peers with no request inside the window are dropped. Sweeps are therefore +/// amortised O(1) per request. pub struct WebhookRateLimiter { - state: Mutex>>, + state: Mutex, window: Duration, limit: u32, } impl WebhookRateLimiter { pub fn new(requests_per_minute: u32) -> Self { + Self::with_window(requests_per_minute, Duration::from_secs(60)) + } + + fn with_window(requests_per_minute: u32, window: Duration) -> Self { Self { - state: Mutex::new(HashMap::new()), - window: Duration::from_secs(60), + state: Mutex::new(LimiterState { + peers: HashMap::new(), + sweep_at: SWEEP_FLOOR, + }), + window, limit: requests_per_minute, } } @@ -51,11 +78,19 @@ impl WebhookRateLimiter { /// Returns `true` if the request is within the allowed rate. pub fn check_ip(&self, ip: IpAddr) -> bool { let now = Instant::now(); + let cutoff = now.checked_sub(self.window).unwrap_or(now); let mut state = self.state.lock().expect("rate limiter mutex poisoned"); - let timestamps = state.entry(ip).or_default(); + + if state.peers.len() >= state.sweep_at { + state + .peers + .retain(|_, times| times.back().is_some_and(|&t| t >= cutoff)); + state.sweep_at = SWEEP_FLOOR.max(state.peers.len().saturating_mul(2)); + } + + let timestamps = state.peers.entry(ip).or_default(); // Evict entries older than the window - let cutoff = now - self.window; while timestamps.front().map(|&t| t < cutoff).unwrap_or(false) { timestamps.pop_front(); } @@ -68,6 +103,15 @@ impl WebhookRateLimiter { } } + #[cfg(test)] + fn tracked_peers(&self) -> usize { + self.state + .lock() + .expect("rate limiter mutex poisoned") + .peers + .len() + } + pub fn limit(&self) -> u32 { self.limit } @@ -155,4 +199,39 @@ mod tests { assert!(!limiter.check_ip(ip1), "ip1 should be blocked"); assert!(limiter.check_ip(ip2), "ip2 should still be allowed"); } + + fn nth_ip(i: usize) -> IpAddr { + IpAddr::V6(std::net::Ipv6Addr::from(0x2001_0db8_u128 << 96 | i as u128)) + } + + #[test] + fn idle_peers_are_swept_once_the_map_is_large() { + let limiter = WebhookRateLimiter::with_window(5, Duration::from_millis(1)); + for i in 0..SWEEP_FLOOR { + assert!(limiter.check_ip(nth_ip(i))); + } + assert_eq!(limiter.tracked_peers(), SWEEP_FLOOR); + std::thread::sleep(Duration::from_millis(5)); + // Every earlier peer is now idle; the next new peer triggers a sweep. + assert!(limiter.check_ip(nth_ip(SWEEP_FLOOR))); + assert_eq!(limiter.tracked_peers(), 1); + } + + #[test] + fn active_peers_survive_a_sweep_and_stay_limited() { + let limiter = WebhookRateLimiter::new(2); + let busy = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9)); + assert!(limiter.check_ip(busy)); + assert!(limiter.check_ip(busy)); + for i in 0..SWEEP_FLOOR { + limiter.check_ip(nth_ip(i)); + } + // A sweep has run (the map reached the floor) inside the window, so + // nothing was idle and `busy` is still at its limit. + assert!(limiter.tracked_peers() > SWEEP_FLOOR); + assert!( + !limiter.check_ip(busy), + "a sweep must not reset an active peer" + ); + } } diff --git a/src/config.rs b/src/config.rs index b062bc0..95556f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,6 +77,15 @@ pub struct Config { /// `OTEL_EXPORTER_OTLP_ENDPOINT` env var (env wins over TOML). #[serde(default)] pub observability: ObservabilityConfig, + + /// GraphQL API access: which repositories the route credential may + /// submit obligations for, and the per-IP rate limit on `/graphql`. + /// The credentials themselves are read only from the environment + /// (`ECHIDNABOT_API_ROUTE_TOKEN`, `ECHIDNABOT_API_ADMIN_TOKEN`). + /// + /// TOML: `[api]\nroute_repos = ["hyperpolymath/*"]\nrate_limit_rpm = 120` + #[serde(default)] + pub api: ApiConfig, } /// Lifecycle settings — how long to wait for in-flight work to drain @@ -311,13 +320,53 @@ impl Default for ServerConfig { } fn default_host() -> String { - "0.0.0.0".to_string() + // Loopback by default: binding every interface is an explicit choice, + // and the daemon refuses it unless API credentials are configured + // (see `api::auth`). + "127.0.0.1".to_string() } fn default_port() -> u16 { 8080 } +/// GraphQL API access settings. The two credentials are not here: they are +/// read only from `ECHIDNABOT_API_ROUTE_TOKEN` and `ECHIDNABOT_API_ADMIN_TOKEN` +/// so they never sit in a config file. +/// +/// ```toml +/// [api] +/// route_repos = ["hyperpolymath/*"] +/// rate_limit_rpm = 120 +/// ``` +#[derive(Debug, Deserialize, Clone)] +pub struct ApiConfig { + /// Repositories the route credential may submit obligations for: + /// exact `owner/name` slugs or `owner/*`. Empty allows none. + #[serde(default)] + pub route_repos: Vec, + + /// Maximum `/graphql` requests per minute per TCP peer address. Default + /// 120; set `0` to disable. Forwarding headers are not trusted, so + /// behind a reverse proxy every client shares the proxy's budget: limit + /// at the proxy and set `0` here. + #[serde(default = "default_api_rate_limit_rpm")] + pub rate_limit_rpm: u32, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + route_repos: Vec::new(), + rate_limit_rpm: default_api_rate_limit_rpm(), + } + } +} + +fn default_api_rate_limit_rpm() -> u32 { + 120 +} + #[derive(Debug, Deserialize, Clone)] pub struct DatabaseConfig { #[serde(default = "default_database_url")] @@ -486,16 +535,33 @@ fn default_queue_size() -> usize { impl Config { /// Load configuration from file pub fn load(path: &str) -> Result { + Self::load_with_env(path, Self::environment()) + } + + /// The `ECHIDNABOT__SECTION__KEY` environment source. + fn environment() -> config::Environment { + config::Environment::with_prefix("ECHIDNABOT").separator("__") + } + + /// [`Config::load`] with the environment source supplied, so tests can + /// pass a fixed map instead of mutating the process environment. + /// + /// The environment applies whether or not the file exists: a container + /// that ships no config file is configured by `ECHIDNABOT__…` variables + /// alone (see `.env.example`). + fn load_with_env(path: &str, env: config::Environment) -> Result { let path = Path::new(path); if !path.exists() { - tracing::warn!("Config file {} not found, using defaults", path.display()); - return Ok(Self::default()); + tracing::warn!( + "Config file {} not found, using defaults and ECHIDNABOT__* overrides", + path.display() + ); } let builder = config::Config::builder() - .add_source(config::File::from(path)) - .add_source(config::Environment::with_prefix("ECHIDNABOT").separator("__")); + .add_source(config::File::from(path).required(false)) + .add_source(env); let config = builder.build()?; let parsed: Config = config.try_deserialize()?; @@ -580,3 +646,43 @@ mod redaction_tests { } } } + +#[cfg(test)] +mod load_tests { + use super::*; + + fn env(pairs: &[(&str, &str)]) -> config::Environment { + let map: config::Map = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + Config::environment().source(Some(map)) + } + + /// A container ships no config file and sets `ECHIDNABOT__SERVER__HOST` + /// in `.env`: the override must apply, not the loopback default. + #[test] + fn environment_applies_when_the_config_file_is_missing() { + let missing = "/nonexistent/echidnabot-test-config.toml"; + let c = Config::load_with_env( + missing, + env(&[ + ("ECHIDNABOT__SERVER__HOST", "0.0.0.0"), + ("ECHIDNABOT__SERVER__PORT", "9091"), + ]), + ) + .unwrap(); + assert_eq!(c.server.host, "0.0.0.0"); + assert_eq!(c.server.port, 9091); + } + + #[test] + fn missing_file_and_no_overrides_gives_the_defaults() { + let c = + Config::load_with_env("/nonexistent/echidnabot-test-config.toml", env(&[])).unwrap(); + assert_eq!(c.server.host, "127.0.0.1"); + assert_eq!(c.server.port, 8080); + assert_eq!(c.api.rate_limit_rpm, 120); + assert!(c.api.route_repos.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 34b90c7..051c924 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,8 @@ enum Commands { /// Start the webhook server Serve { /// Host to bind to. If omitted, reads `[server].host` from the TOML - /// config (which itself defaults to `0.0.0.0` if unset there). + /// config (which itself defaults to `127.0.0.1` if unset there). A + /// non-loopback host needs API credentials; see `[api]` in the docs. #[arg(short = 'H', long)] host: Option, @@ -251,8 +252,42 @@ async fn serve( port: u16, tracer_hook: Option, ) -> Result<()> { - use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; - use axum::{routing::get, routing::post, Extension, Router}; + use axum::{routing::get, Extension, Router}; + use echidnabot::api::auth::{self as api_auth, ApiCredentials, ApiGate, RepoScope}; + + // GraphQL API credentials. Refuse to start rather than serve the schema + // unauthenticated on a reachable address: before this gate, any caller + // could rewrite a repository webhook secret or write training rows. + let credentials = ApiCredentials::from_env(RepoScope::new(config.api.route_repos.clone())) + .map_err(|e| echidnabot::Error::Config(format!("API credentials: {e}")))?; + if credentials.is_empty() { + if !api_auth::is_loopback_host(host) { + return Err(echidnabot::Error::Config(format!( + "refusing to bind {host}: set {} and/or {} before serving \ + /graphql on a non-loopback address", + api_auth::ROUTE_TOKEN_ENV, + api_auth::ADMIN_TOKEN_ENV + ))); + } + tracing::warn!( + "No API credentials set: every /graphql request will be refused \ + with 401. Set {} / {} to use the API.", + api_auth::ROUTE_TOKEN_ENV, + api_auth::ADMIN_TOKEN_ENV + ); + } + if credentials.has_route_token() && credentials.route_scope().is_empty() { + tracing::warn!( + "{} is set but [api] route_repos is empty: the route credential \ + can submit obligations for no repository.", + api_auth::ROUTE_TOKEN_ENV + ); + } + let api_rate_limit = (config.api.rate_limit_rpm > 0).then_some(config.api.rate_limit_rpm); + if api_rate_limit.is_none() { + tracing::warn!("/graphql rate limiting is disabled ([api] rate_limit_rpm = 0)"); + } + let api_gate = Arc::new(ApiGate::new(credentials, api_rate_limit)); // Webhook signature verification is per-integration (handled in // src/api/webhooks.rs). When no `webhook_secret` is configured for a @@ -329,13 +364,7 @@ async fn serve( .route("/", get(root)) .route( "/graphql", - post( - |Extension(schema): Extension, - req: GraphQLRequest| async move { - GraphQLResponse::from(schema.execute(req.into_inner()).await) - }, - ) - .get(graphql_playground), + api_auth::graphql_post_route(api_gate.clone()).get(graphql_playground), ) .merge(webhook_router(app_state.clone())) .layer(Extension(schema)) diff --git a/src/trust/solver_integrity.rs b/src/trust/solver_integrity.rs index fad4a36..b47d620 100644 --- a/src/trust/solver_integrity.rs +++ b/src/trust/solver_integrity.rs @@ -238,7 +238,7 @@ fn prover_key(prover: &ProverKind) -> String { /// Constant-time byte comparison to prevent timing side channels. /// /// Returns true if both slices are equal in length and content. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } diff --git a/tests/protocol_contract.rs b/tests/protocol_contract.rs index ca7478b..6a81032 100644 --- a/tests/protocol_contract.rs +++ b/tests/protocol_contract.rs @@ -160,9 +160,38 @@ async fn obligation_schema() -> echidnabot::api::graphql::EchidnabotSchema { }) } -/// Execute `query` and return the response as JSON (data + errors). +/// The principal Hypatia holds: the route credential, scoped as a deployment +/// would scope it, plus the slug the contract tests use for invalid inputs. +fn hypatia_principal() -> echidnabot::api::auth::Principal { + echidnabot::api::auth::Principal::Route(echidnabot::api::auth::RepoScope::new([ + "hyperpolymath/*", + "a/b", + ])) +} + +/// Execute `query` as Hypatia and return the response as JSON (data + errors). async fn run(schema: &echidnabot::api::graphql::EchidnabotSchema, query: &str) -> Value { - serde_json::to_value(schema.execute(query).await).unwrap() + run_as(schema, query, Some(hypatia_principal())).await +} + +/// Execute `query` as `principal` (or with none) and return the response. +async fn run_as( + schema: &echidnabot::api::graphql::EchidnabotSchema, + query: &str, + principal: Option, +) -> Value { + let mut request = async_graphql::Request::new(query); + if let Some(p) = principal { + request = request.data(p); + } + serde_json::to_value(schema.execute(request).await).unwrap() +} + +/// The `code` extension of the first error, if any. +fn error_code(resp: &Value) -> Option { + resp["errors"][0]["extensions"]["code"] + .as_str() + .map(str::to_string) } /// FleetDispatcher's mutation; `prover_line` is "" or " prover: X,\n". @@ -272,3 +301,83 @@ async fn invalid_obligations_are_graphql_errors_not_success_false() { ); } } + +// --------------------------------------------------------------------------- +// Authorization inside the schema. The HTTP gate (src/api/auth.rs) answers +// 401 before the schema runs; these tests show the field guards fail closed +// for anything that executes the schema directly. +// --------------------------------------------------------------------------- + +const UPDATE_SETTINGS: &str = + "mutation { updateRepoSettings(repoId: \"00000000-0000-0000-0000-000000000000\", \ + settings: { webhookSecret: \"forged\" }) { id } }"; +const RECORD_OUTCOME: &str = "mutation { recordTacticOutcome(input: { \ + prover: COQ, goalState: \"g\", tactic: \"auto\", succeeded: true, durationMs: 1 }) { id } }"; +const REGISTER_REPO: &str = "mutation { registerRepository(input: { \ + platform: GIT_HUB, owner: \"someone-else\", name: \"repo\", webhookSecret: \"forged\" }) { id } }"; +const TRIGGER_CHECK: &str = + "mutation { triggerCheck(repoId: \"00000000-0000-0000-0000-000000000000\") { id } }"; + +#[tokio::test] +async fn obligation_outside_the_route_scope_is_forbidden() { + let schema = obligation_schema().await; + let q = fleet_mutation("someone-else/repo", "forall x, x = x", "pattern P1", ""); + let resp = run(&schema, &q).await; + assert_eq!(error_code(&resp).as_deref(), Some("FORBIDDEN"), "{resp}"); + assert!(resp["data"].is_null(), "{resp}"); +} + +#[tokio::test] +async fn schema_executed_without_a_principal_refuses_the_guarded_fields() { + let schema = obligation_schema().await; + let submit = fleet_mutation("hyperpolymath/echidnabot", "c", "d", ""); + for q in [ + submit.as_str(), + UPDATE_SETTINGS, + RECORD_OUTCOME, + REGISTER_REPO, + TRIGGER_CHECK, + "{ repositories { id } }", + ] { + let resp = run_as(&schema, q, None).await; + assert_eq!( + error_code(&resp).as_deref(), + Some("UNAUTHENTICATED"), + "{q:.60}: {resp}" + ); + } +} + +#[tokio::test] +async fn route_credential_cannot_reach_administrative_fields() { + let schema = obligation_schema().await; + for q in [ + UPDATE_SETTINGS, + RECORD_OUTCOME, + REGISTER_REPO, + TRIGGER_CHECK, + "{ repositories { id } }", + ] { + let resp = run(&schema, q).await; + assert_eq!( + error_code(&resp).as_deref(), + Some("FORBIDDEN"), + "{q:.60}: {resp}" + ); + } +} + +#[tokio::test] +async fn admin_credential_passes_the_guards() { + let schema = obligation_schema().await; + let admin = Some(echidnabot::api::auth::Principal::Admin); + let resp = run_as(&schema, "{ repositories { id } }", admin.clone()).await; + assert!( + resp.get("errors") + .is_none_or(|e| e.as_array().unwrap().is_empty()), + "{resp}" + ); + // Any repository, in scope or not. + let q = fleet_mutation("someone-else/repo", "forall x, x = x", "pattern P1", ""); + accepted(&run_as(&schema, &q, admin).await); +} diff --git a/tests/smoke.rs b/tests/smoke.rs index 369ad42..501878a 100644 --- a/tests/smoke.rs +++ b/tests/smoke.rs @@ -7,9 +7,9 @@ //! Verifies: "does it start, does it respond, are the obvious paths alive?" //! Runs before deeper tests in CI to fail-fast on build regressions. -use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{routing::get, Extension, Router}; use axum_test::TestServer; +use echidnabot::api::auth::{graphql_post_route, ApiCredentials, ApiGate, RepoScope}; use echidnabot::api::graphql::GraphQLState; use echidnabot::api::rate_limit::WebhookRateLimiter; use echidnabot::api::webhooks::AppState; @@ -21,6 +21,10 @@ use echidnabot::scheduler::JobScheduler; use echidnabot::store::SqliteStore; use std::sync::Arc; +// Test-only credentials (the `test-` prefix marks them for the secret scanner). +const ROUTE_TOKEN: &str = "test-route-credential-0123456789abcdef"; +const ADMIN_TOKEN: &str = "test-admin-credential-0123456789abcdef"; + async fn make_test_server() -> TestServer { let config = Arc::new(Config::default()); let store = Arc::new(SqliteStore::new("sqlite::memory:").await.unwrap()); @@ -42,17 +46,17 @@ async fn make_test_server() -> TestServer { mode_selector: ModeSelector::default(), }; + let credentials = ApiCredentials::new( + Some(ROUTE_TOKEN.to_string()), + Some(ADMIN_TOKEN.to_string()), + RepoScope::new(["hyperpolymath/*"]), + ) + .unwrap(); + let gate = Arc::new(ApiGate::new(credentials, None)); + let app = Router::new() .route("/health", get(|| async { "OK" })) - .route( - "/graphql", - axum::routing::post( - |Extension(schema): Extension, - req: GraphQLRequest| async move { - GraphQLResponse::from(schema.execute(req.into_inner()).await) - }, - ), - ) + .route("/graphql", graphql_post_route(gate)) .merge(webhook_router(app_state.clone())) .layer(Extension(schema)) .with_state(app_state); @@ -123,6 +127,7 @@ async fn smoke_graphql_typename_query() { let server = make_test_server().await; let response = server .post("/graphql") + .authorization_bearer(ADMIN_TOKEN) .json(&serde_json::json!({ "query": "{ __typename }" })) .await; response.assert_status_ok(); @@ -142,6 +147,7 @@ async fn smoke_graphql_repositories_empty() { let server = make_test_server().await; let response = server .post("/graphql") + .authorization_bearer(ADMIN_TOKEN) .json(&serde_json::json!({ "query": "{ repositories { id name } }" })) .await; response.assert_status_ok(); @@ -215,3 +221,118 @@ async fn smoke_rate_limiting_returns_429_at_limit() { "rate-limited response must include Retry-After header" ); } + +#[tokio::test] +async fn smoke_graphql_without_credential_is_401() { + let server = make_test_server().await; + let response = server + .post("/graphql") + .json(&serde_json::json!({ "query": "{ __typename }" })) + .await; + response.assert_status(axum::http::StatusCode::UNAUTHORIZED); + assert_eq!( + response.header("www-authenticate").to_str().unwrap(), + "Bearer" + ); +} + +#[tokio::test] +async fn smoke_graphql_with_wrong_credential_is_401() { + let server = make_test_server().await; + let response = server + .post("/graphql") + .authorization_bearer("test-not-a-configured-credential-000000") + .json(&serde_json::json!({ "query": "{ __typename }" })) + .await; + response.assert_status(axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn smoke_graphql_route_credential_cannot_rewrite_webhook_secret() { + let server = make_test_server().await; + let response = server + .post("/graphql") + .authorization_bearer(ROUTE_TOKEN) + .json(&serde_json::json!({ + "query": "mutation { updateRepoSettings(repoId: \"00000000-0000-0000-0000-000000000000\", \ + settings: { webhookSecret: \"forged\" }) { id } }" + })) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["extensions"]["code"], + serde_json::json!("FORBIDDEN"), + "{body}" + ); +} + +#[tokio::test] +async fn smoke_graphql_route_credential_submits_in_scope() { + let server = make_test_server().await; + let response = server + .post("/graphql") + .authorization_bearer(ROUTE_TOKEN) + .json(&serde_json::json!({ + "query": "mutation { submitProofObligation(input: { repo: \"hyperpolymath/hypatia\", \ + claim: \"forall x, x = x\", context: \"smoke\" }) { success } }" + })) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!( + body["data"]["submitProofObligation"]["success"], + serde_json::json!(true), + "{body}" + ); +} + +#[tokio::test] +async fn smoke_graphql_rate_limit_returns_429() { + let config = Arc::new(Config::default()); + let store = Arc::new(SqliteStore::new("sqlite::memory:").await.unwrap()); + let scheduler = Arc::new(JobScheduler::new(2, 10)); + let echidna = Arc::new(EchidnaClient::new(&config.echidna)); + let schema = create_schema(GraphQLState { + store: store.clone(), + scheduler: scheduler.clone(), + echidna, + }); + let app_state = AppState { + config, + store, + scheduler, + rate_limiter: None, + mode_selector: ModeSelector::default(), + }; + let credentials = + ApiCredentials::new(None, Some(ADMIN_TOKEN.to_string()), RepoScope::default()).unwrap(); + let gate = Arc::new(ApiGate::new(credentials, Some(2))); + let app = Router::new() + .route("/graphql", graphql_post_route(gate)) + .layer(Extension(schema)) + .with_state(app_state); + let server = TestServer::new(app.into_make_service_with_connect_info::()); + + for i in 0..2 { + let r = server + .post("/graphql") + .authorization_bearer(ADMIN_TOKEN) + .json(&serde_json::json!({ "query": "{ __typename }" })) + .await; + assert_eq!(r.status_code(), 200, "request {i} should pass"); + } + // An authenticated caller over the limit is refused... + let r = server + .post("/graphql") + .authorization_bearer(ADMIN_TOKEN) + .json(&serde_json::json!({ "query": "{ __typename }" })) + .await; + r.assert_status(axum::http::StatusCode::TOO_MANY_REQUESTS); + // ...and, limited before authentication, so is an unauthenticated flood. + let r = server + .post("/graphql") + .json(&serde_json::json!({ "query": "{ __typename }" })) + .await; + r.assert_status(axum::http::StatusCode::TOO_MANY_REQUESTS); +} diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 5311114..e75fedf 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -206,6 +206,8 @@ echidnabot provers list | `ECHIDNABOT_CONFIG` | Path to config file | | `ECHIDNABOT_DATABASE_URL` | Database connection string | | `ECHIDNABOT_ECHIDNA_ENDPOINT` | ECHIDNA Core GraphQL URL | +| `ECHIDNABOT_API_ROUTE_TOKEN` | Bearer credential that may only submit proof obligations, for `[api] route_repos` | +| `ECHIDNABOT_API_ADMIN_TOKEN` | Bearer credential for the whole GraphQL API | | `GITHUB_WEBHOOK_SECRET` | GitHub webhook secret | | `GITLAB_TOKEN` | GitLab access token |