Skip to content

Commit 08d4beb

Browse files
authored
feat: enforce API key authentication on all data routes (#45)
1 parent 28e8c49 commit 08d4beb

8 files changed

Lines changed: 222 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/reasondb-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ tower = "0.4"
2828
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
2929

3030
# Async
31+
async-trait = "0.1"
3132
tokio.workspace = true
3233
tokio-stream = "0.1"
3334
futures.workspace = true

crates/reasondb-server/src/auth.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
//! - API key authentication via `X-API-Key: <key>` header
66
//! - Optional authentication (for public endpoints)
77
8+
use async_trait::async_trait;
89
use axum::{
10+
extract::{FromRequestParts, Request, State},
911
http::{header, request::Parts, StatusCode},
12+
middleware::Next,
1013
response::{IntoResponse, Response},
1114
Json,
1215
};
1316
use reasondb_core::{ApiKey, KeyPrefix, Permission, Permissions};
1417
use serde::Serialize;
18+
use std::sync::Arc;
19+
20+
use crate::state::AppState;
1521

1622
/// Authenticated API key (extracted from request)
1723
#[derive(Debug, Clone)]
@@ -174,3 +180,106 @@ pub fn extract_api_key(parts: &Parts) -> Option<String> {
174180

175181
None
176182
}
183+
184+
/// Axum middleware that enforces API key authentication on all routes when
185+
/// `REASONDB_AUTH_ENABLED=true`.
186+
///
187+
/// Public routes (`/health`, `/metrics`, `/swagger-ui`, `/api-docs`) bypass
188+
/// auth so monitoring and documentation remain accessible without a key.
189+
pub async fn auth_middleware<
190+
R: reasondb_core::llm::ReasoningEngine + Clone + Send + Sync + 'static,
191+
>(
192+
State(state): State<Arc<AppState<R>>>,
193+
request: Request,
194+
next: Next,
195+
) -> Response {
196+
// Auth disabled — pass through
197+
if !state.config.auth.enabled {
198+
return next.run(request).await;
199+
}
200+
201+
// Public paths that never require auth
202+
let path = request.uri().path().to_owned();
203+
if path == "/health"
204+
|| path == "/metrics"
205+
|| path.starts_with("/swagger-ui")
206+
|| path.starts_with("/api-docs")
207+
{
208+
return next.run(request).await;
209+
}
210+
211+
// Extract API key from headers before consuming the request
212+
let raw_key = {
213+
let headers = request.headers();
214+
// Try Authorization: Bearer <key>
215+
let from_auth = headers
216+
.get(header::AUTHORIZATION)
217+
.and_then(|v| v.to_str().ok())
218+
.and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()));
219+
// Fall back to X-API-Key
220+
let from_x_key = headers
221+
.get("X-API-Key")
222+
.and_then(|v| v.to_str().ok())
223+
.map(|s| s.trim().to_string());
224+
from_auth.or(from_x_key)
225+
};
226+
227+
let raw_key = match raw_key {
228+
Some(k) => k,
229+
None => return AuthError::MissingKey.into_response(),
230+
};
231+
232+
// Validate against master key
233+
if let Some(ref master_key) = state.config.auth.master_key {
234+
if raw_key == *master_key {
235+
return next.run(request).await;
236+
}
237+
}
238+
239+
// Validate against stored API keys
240+
match state.api_key_store.authenticate(&raw_key) {
241+
Ok(Some(key)) if key.is_active => next.run(request).await,
242+
Ok(Some(_)) => AuthError::RevokedKey.into_response(),
243+
Ok(None) => AuthError::InvalidKey.into_response(),
244+
Err(e) => AuthError::Internal(e.to_string()).into_response(),
245+
}
246+
}
247+
248+
/// `FromRequestParts` impl so handlers can optionally extract an
249+
/// `AuthenticatedKey` without the middleware (used by auth management routes).
250+
#[async_trait]
251+
impl<R> FromRequestParts<Arc<AppState<R>>> for AuthenticatedKey
252+
where
253+
R: reasondb_core::llm::ReasoningEngine + Clone + Send + Sync + 'static,
254+
{
255+
type Rejection = AuthError;
256+
257+
async fn from_request_parts(
258+
parts: &mut Parts,
259+
state: &Arc<AppState<R>>,
260+
) -> Result<Self, Self::Rejection> {
261+
if !state.config.auth.enabled {
262+
return Ok(AuthenticatedKey::anonymous());
263+
}
264+
265+
let raw_key = extract_api_key(parts).ok_or(AuthError::MissingKey)?;
266+
267+
if let Some(ref master_key) = state.config.auth.master_key {
268+
if raw_key == *master_key {
269+
return Ok(AuthenticatedKey::master());
270+
}
271+
}
272+
273+
let key = state
274+
.api_key_store
275+
.authenticate(&raw_key)
276+
.map_err(|e| AuthError::Internal(e.to_string()))?
277+
.ok_or(AuthError::InvalidKey)?;
278+
279+
if !key.is_active {
280+
return Err(AuthError::RevokedKey);
281+
}
282+
283+
Ok(AuthenticatedKey { key })
284+
}
285+
}

crates/reasondb-server/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ pub mod replication;
6161
pub mod routes;
6262
pub mod state;
6363

64+
pub use auth::auth_middleware;
6465
pub use error::{ApiError, ApiResult, ErrorResponse};
6566
pub use metrics::{init_metrics, metrics_handler, metrics_middleware};
6667
#[cfg(feature = "telemetry")]
@@ -108,6 +109,12 @@ pub fn create_server<R: ReasoningEngine + Clone + Send + Sync + 'static>(
108109
// Add Prometheus metrics endpoint
109110
app = app.route("/metrics", get(metrics::metrics_handler));
110111

112+
// Add API key authentication middleware (enforces auth when REASONDB_AUTH_ENABLED=true)
113+
app = app.layer(axum::middleware::from_fn_with_state(
114+
state.clone(),
115+
auth_middleware,
116+
));
117+
111118
// Add rate limiting middleware
112119
if state.config.rate_limit.enabled {
113120
info!(

deploy/aws/terraform/main.tf

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ terraform {
55
source = "hashicorp/aws"
66
version = "~> 5.0"
77
}
8+
null = {
9+
source = "hashicorp/null"
10+
version = "~> 3.0"
11+
}
812
}
913
}
1014

@@ -103,6 +107,8 @@ resource "aws_instance" "reasondb" {
103107
llm_model = var.llm_model
104108
llm_base_url = var.llm_base_url
105109
reasondb_image = var.reasondb_image
110+
auth_enabled = var.auth_enabled
111+
master_key = var.master_key
106112
})
107113

108114
tags = { Name = "${var.name_prefix}-instance" }
@@ -144,3 +150,68 @@ resource "aws_eip" "reasondb" {
144150

145151
depends_on = [aws_instance.reasondb]
146152
}
153+
154+
# ---------------------------------------------------------------------------
155+
# Container updater — re-runs whenever image or config variables change.
156+
#
157+
# user_data only executes once at first boot, so this null_resource handles
158+
# zero-downtime container updates on subsequent `terraform apply` runs by
159+
# SSH-ing into the instance and restarting the Docker container in-place.
160+
# ---------------------------------------------------------------------------
161+
162+
resource "null_resource" "container_update" {
163+
# Re-trigger whenever any of these values change
164+
triggers = {
165+
image = var.reasondb_image
166+
llm_provider = var.llm_provider
167+
llm_model = var.llm_model
168+
llm_base_url = var.llm_base_url
169+
auth_enabled = tostring(var.auth_enabled)
170+
# Use a hash of the key so the value doesn't appear in state
171+
master_key_hash = sha256(var.master_key)
172+
llm_key_hash = sha256(var.llm_api_key)
173+
# Re-run if the instance is replaced
174+
instance_id = aws_instance.reasondb.id
175+
}
176+
177+
connection {
178+
type = "ssh"
179+
user = "ubuntu"
180+
private_key = file(var.ssh_private_key_path)
181+
host = aws_eip.reasondb.public_ip
182+
}
183+
184+
provisioner "remote-exec" {
185+
inline = [
186+
"echo '=== Pulling ${var.reasondb_image} ==='",
187+
"sudo docker pull ${var.reasondb_image}",
188+
"echo '=== Stopping existing container ==='",
189+
"sudo docker stop reasondb 2>/dev/null || true",
190+
"sudo docker rm reasondb 2>/dev/null || true",
191+
"echo '=== Starting updated container ==='",
192+
"sudo docker run -d \\",
193+
" --name reasondb \\",
194+
" --restart unless-stopped \\",
195+
" -p 4444:4444 \\",
196+
" -v /data:/data \\",
197+
" -e REASONDB_HOST=0.0.0.0 \\",
198+
" -e REASONDB_PORT=4444 \\",
199+
" -e REASONDB_PATH=/data/reasondb.redb \\",
200+
" -e REASONDB_LLM_PROVIDER=${var.llm_provider} \\",
201+
" -e REASONDB_LLM_API_KEY=${var.llm_api_key} \\",
202+
" -e REASONDB_MODEL=${var.llm_model} \\",
203+
" -e REASONDB_LLM_BASE_URL=${var.llm_base_url} \\",
204+
" -e REASONDB_RATE_LIMIT_RPM=300 \\",
205+
" -e REASONDB_RATE_LIMIT_RPH=5000 \\",
206+
" -e REASONDB_RATE_LIMIT_BURST=30 \\",
207+
" -e REASONDB_WORKER_COUNT=4 \\",
208+
" -e REASONDB_AUTH_ENABLED=${var.auth_enabled} \\",
209+
" -e REASONDB_MASTER_KEY=${var.master_key} \\",
210+
" ${var.reasondb_image}",
211+
"echo '=== Waiting for health check ==='",
212+
"for i in $(seq 1 24); do curl -sf http://localhost:4444/health && echo '' && break || sleep 5; done",
213+
]
214+
}
215+
216+
depends_on = [aws_eip.reasondb, aws_volume_attachment.data]
217+
}

deploy/aws/terraform/terraform.tfvars.example

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ instance_type = "t3.medium"
1111
# EBS data volume size in GB
1212
volume_size_gb = 20
1313

14-
# Paste the contents of your ~/.ssh/id_rsa.pub (or equivalent)
15-
ssh_public_key = "ssh-rsa AAAA... you@example.com"
14+
# Paste the contents of your ~/.ssh/id_ed25519.pub (or equivalent)
15+
ssh_public_key = "ssh-ed25519 AAAA... you@example.com"
16+
ssh_private_key_path = "~/.ssh/id_ed25519"
1617

1718
# Restrict access to your IP for security, or use 0.0.0.0/0 for open access
1819
allowed_cidr = "0.0.0.0/0"
@@ -28,5 +29,11 @@ llm_api_key = "sk-..."
2829
# Optional: custom base URL (e.g. for Ollama or a proxy)
2930
# llm_base_url = "http://localhost:11434/v1"
3031

31-
# Docker image (use a specific version tag in production, e.g. "ajainvivek/reasondb:0.1.0")
32-
reasondb_image = "ajainvivek/reasondb:latest"
32+
# Docker image (use a specific version tag in production, e.g. "brainfishai/reasondb:0.5.7")
33+
reasondb_image = "brainfishai/reasondb:latest"
34+
35+
# ── Authentication ─────────────────────────────────────────────────────────────
36+
# Enable API key auth (recommended for any non-local deployment)
37+
auth_enabled = true
38+
# Master admin key — generate with: python3 -c "import secrets; print('rdb_master_' + secrets.token_hex(24))"
39+
master_key = "rdb_master_..."

deploy/aws/terraform/user_data.sh.tpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,6 @@ docker run -d \
6868
-e REASONDB_RATE_LIMIT_RPH="5000" \
6969
-e REASONDB_RATE_LIMIT_BURST="30" \
7070
-e REASONDB_WORKER_COUNT="4" \
71+
-e REASONDB_AUTH_ENABLED="${auth_enabled}" \
72+
-e REASONDB_MASTER_KEY="${master_key}" \
7173
${reasondb_image}

deploy/aws/terraform/variables.tf

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@ variable "volume_size_gb" {
2323
}
2424

2525
variable "ssh_public_key" {
26-
description = "SSH public key material (contents of ~/.ssh/id_rsa.pub or similar)"
26+
description = "SSH public key material (contents of ~/.ssh/id_ed25519.pub or similar)"
2727
type = string
2828
}
2929

30+
variable "ssh_private_key_path" {
31+
description = "Path to the SSH private key file used by the container updater provisioner (e.g. ~/.ssh/id_ed25519)"
32+
type = string
33+
default = "~/.ssh/id_ed25519"
34+
}
35+
3036
variable "allowed_cidr" {
3137
description = "CIDR block allowed to reach port 4444 and 22. Use your IP (e.g. 1.2.3.4/32) or 0.0.0.0/0 for open access."
3238
type = string
@@ -68,3 +74,16 @@ variable "name_prefix" {
6874
type = string
6975
default = "reasondb-testing"
7076
}
77+
78+
variable "auth_enabled" {
79+
description = "Enable API key authentication (recommended for production)"
80+
type = bool
81+
default = false
82+
}
83+
84+
variable "master_key" {
85+
description = "Master admin key for the ReasonDB instance (required when auth_enabled = true)"
86+
type = string
87+
sensitive = true
88+
default = ""
89+
}

0 commit comments

Comments
 (0)