Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion crates/client/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -206,9 +207,19 @@ impl Client {
/// Creates a new API client with the given URL.
pub fn new(url: impl IntoUrl, auth_token: Option<Secret<String>>) -> Result<Self> {
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,
})
Expand Down
1 change: 1 addition & 0 deletions crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
11 changes: 11 additions & 0 deletions crates/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret>`, or by CLI arg with
`--global-auth-token <secret>`.
If this is set, then requests to the server must contain the `Authorization`
header with the `Bearer <secret>` token.
84 changes: 84 additions & 0 deletions crates/server/src/api/auth_layer.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl AuthLayer {
pub fn new<S: Into<String>>(secret: S) -> Self {
AuthLayer {
secret: Arc::new(secret.into()),
}
}
}

impl<S> Layer<S> for AuthLayer {
type Service = AuthService<S>;

fn layer(&self, inner: S) -> Self::Service {
AuthService {
inner,

secret: self.secret.clone(),
}
}
}

#[derive(Clone)]
pub struct AuthService<S> {
inner: S,

secret: Arc<String>,
}

impl<S, B> tower::Service<Request<B>> for AuthService<S>
where
S: tower::Service<Request<B>, Response = Response> + Send + 'static,

S::Future: Send + 'static,

B: Send + 'static,
{
type Response = Response;

type Error = S::Error;

type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}

fn call(&mut self, req: Request<B>) -> 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())
})
}
}
11 changes: 9 additions & 2 deletions crates/server/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::api::auth_layer::AuthLayer;
use crate::{
policy::{content::ContentPolicy, record::RecordPolicy},
services::CoreService,
Expand All @@ -14,6 +15,7 @@ use tower_http::{
use tracing::{Level, Span};
use url::Url;

mod auth_layer;
pub mod v1;

#[cfg(feature = "debug")]
Expand All @@ -27,11 +29,12 @@ pub fn create_router(
files_dir: PathBuf,
content_policy: Option<Arc<dyn ContentPolicy>>,
record_policy: Option<Arc<dyn RecordPolicy>>,
global_auth_token: Option<String>,
) -> 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(
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions crates/server/src/bin/warg-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ struct Args {
#[arg(long, env = "WARG_CONTENT_BASE_URL")]
content_base_url: Option<Url>,

/// 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<String>,

/// The data store to use for the server.
#[arg(long, env = "WARG_DATA_STORE", default_value = "memory")]
data_store: DataStoreKind,
Expand Down Expand Up @@ -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:?}"))?;
Expand Down
9 changes: 9 additions & 0 deletions crates/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct Config {
addr: Option<SocketAddr>,
data_store: Option<Box<dyn DataStore>>,
content_dir: PathBuf,
global_auth_token: Option<String>,
content_base_url: Option<Url>,
shutdown: Option<ShutdownFut>,
checkpoint_interval: Option<Duration>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) -> Self {
self.global_auth_token = global_auth_token;
self
}
}

/// Represents the warg registry server.
Expand Down Expand Up @@ -225,6 +233,7 @@ impl Server {
files_dir,
self.config.content_policy,
self.config.record_policy,
self.config.global_auth_token,
);

Ok(InitializedServer {
Expand Down