diff --git a/Cargo.lock b/Cargo.lock index c5080af5..21fe403d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4404,6 +4404,7 @@ dependencies = [ "diesel_json", "diesel_migrations", "futures", + "futures-util", "indexmap 2.2.6", "secrecy", "serde", diff --git a/crates/client/src/api.rs b/crates/client/src/api.rs index 8b600a96..2947dbc8 100644 --- a/crates/client/src/api.rs +++ b/crates/client/src/api.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Result}; use bytes::Bytes; use futures_util::{future::ready, stream::once, Stream, StreamExt, TryStreamExt}; use indexmap::IndexMap; +use reqwest::header::AUTHORIZATION; use reqwest::{ header::{HeaderMap, HeaderValue}, Body, IntoUrl, Method, RequestBuilder, Response, StatusCode, @@ -206,9 +207,19 @@ impl Client { /// Creates a new API client with the given URL. pub fn new(url: impl IntoUrl, auth_token: Option>) -> Result { let url = RegistryUrl::new(url)?; + let mut headers = HeaderMap::new(); + if let Some(token) = &auth_token { + headers.append( + AUTHORIZATION, + format!("Bearer {}", token.expose_secret()).parse()?, + ); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .build()?; Ok(Self { url, - client: reqwest::Client::new(), + client, warg_registry_header: None, auth_token, }) diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index f12fb42d..4a290651 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -40,6 +40,7 @@ diesel_migrations = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true, features = ["postgres"] } serde_json = { workspace = true, optional = true } chrono = { workspace = true, optional = true } +futures-util = { workspace = true } [features] default = [] diff --git a/crates/server/README.md b/crates/server/README.md index 02709fd0..44198ea8 100644 --- a/crates/server/README.md +++ b/crates/server/README.md @@ -63,3 +63,14 @@ WARG_NAMESPACE=example WARG_DATABASE_URL=postgres://postgres:password@localhost/ The `--data-store postgres` flag starts the server with PostgreSQL data storage. The server may now be restarted and will continue to use the same database. + +### Authorization + +By default, the server is publicly accessible with authorization +managed by the operator logs and key IDs. + +It is possible to secure all endpoints in the registry with a global bearer +token by env, with `WARG_GLOBAL_AUTH_TOKEN=`, or by CLI arg with +`--global-auth-token `. +If this is set, then requests to the server must contain the `Authorization` +header with the `Bearer ` token. diff --git a/crates/server/src/api/auth_layer.rs b/crates/server/src/api/auth_layer.rs new file mode 100644 index 00000000..5d26df42 --- /dev/null +++ b/crates/server/src/api/auth_layer.rs @@ -0,0 +1,84 @@ +//! An authentication layer to secure endpoints with a bearer token. +use axum::{ + http::{Request, StatusCode}, + response::Response, +}; +use futures::future::BoxFuture; +use std::{ + sync::Arc, + task::{Context, Poll}, +}; +use tower::Layer; + +#[derive(Clone)] +pub struct AuthLayer { + secret: Arc, +} + +impl AuthLayer { + pub fn new>(secret: S) -> Self { + AuthLayer { + secret: Arc::new(secret.into()), + } + } +} + +impl Layer for AuthLayer { + type Service = AuthService; + + fn layer(&self, inner: S) -> Self::Service { + AuthService { + inner, + + secret: self.secret.clone(), + } + } +} + +#[derive(Clone)] +pub struct AuthService { + inner: S, + + secret: Arc, +} + +impl tower::Service> for AuthService +where + S: tower::Service, Response = Response> + Send + 'static, + + S::Future: Send + 'static, + + B: Send + 'static, +{ + type Response = Response; + + type Error = S::Error; + + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let secret = self.secret.clone(); + let expected = format!("Bearer {}", secret); + let header_value_opt = req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()); + if let Some(header_value) = header_value_opt { + if header_value == expected { + // The header is correct, forward the request. + return Box::pin(self.inner.call(req)); + } + } + // The header is incorrect, return an Unauthorized response. + Box::pin(async move { + Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Unauthorized".into()) + .unwrap()) + }) + } +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 72e779fb..67c2f1af 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -1,3 +1,4 @@ +use crate::api::auth_layer::AuthLayer; use crate::{ policy::{content::ContentPolicy, record::RecordPolicy}, services::CoreService, @@ -14,6 +15,7 @@ use tower_http::{ use tracing::{Level, Span}; use url::Url; +mod auth_layer; pub mod v1; #[cfg(feature = "debug")] @@ -27,11 +29,12 @@ pub fn create_router( files_dir: PathBuf, content_policy: Option>, record_policy: Option>, + global_auth_token: Option, ) -> Router { let router = Router::new(); #[cfg(feature = "debug")] let router = router.nest("/debug", debug::Config::new(core.clone()).into_router()); - router + let mut router = router .nest( "/v1", v1::create_router( @@ -67,5 +70,9 @@ pub fn create_router( axum::http::header::ACCEPT, ]), ), - ) + ); + if let Some(token) = global_auth_token { + router = router.layer(AuthLayer::new(token)); + } + router } diff --git a/crates/server/src/bin/warg-server.rs b/crates/server/src/bin/warg-server.rs index 3a092986..50d4e698 100644 --- a/crates/server/src/bin/warg-server.rs +++ b/crates/server/src/bin/warg-server.rs @@ -35,6 +35,11 @@ struct Args { #[arg(long, env = "WARG_CONTENT_BASE_URL")] content_base_url: Option, + /// An optional bearer auth token that, if set, will be required on + /// all requests to the registry under the authorization header. + #[arg(long, env = "WARG_GLOBAL_AUTH_TOKEN")] + global_auth_token: Option, + /// The data store to use for the server. #[arg(long, env = "WARG_DATA_STORE", default_value = "memory")] data_store: DataStoreKind, @@ -112,6 +117,10 @@ async fn main() -> Result<()> { config = config.with_content_base_url(url); } + if let Some(global_auth_token) = args.global_auth_token { + config = config.with_global_auth_token(Some(global_auth_token)); + } + if let Some(path) = args.authorized_keys_file { let authorized_keys_data = std::fs::read_to_string(&path) .with_context(|| format!("failed to read authorized keys from {path:?}"))?; diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index b574ea38..2104a4d8 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -29,6 +29,7 @@ pub struct Config { addr: Option, data_store: Option>, content_dir: PathBuf, + global_auth_token: Option, content_base_url: Option, shutdown: Option, checkpoint_interval: Option, @@ -74,6 +75,7 @@ impl Config { addr: None, data_store: None, content_dir, + global_auth_token: None, content_base_url: None, shutdown: None, checkpoint_interval: None, @@ -140,6 +142,12 @@ impl Config { self.record_policy = Some(Arc::new(policy)); self } + + /// Sets the optional global auth token. + pub fn with_global_auth_token(mut self, global_auth_token: Option) -> Self { + self.global_auth_token = global_auth_token; + self + } } /// Represents the warg registry server. @@ -225,6 +233,7 @@ impl Server { files_dir, self.config.content_policy, self.config.record_policy, + self.config.global_auth_token, ); Ok(InitializedServer {