Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
be9edcc
fix(ci): drop job-level environment key from release-contract
aliasfoxkde Oct 30, 2026
89bb59e
fix(test): replace iter::repeat collect with str repeat
aliasfoxkde Oct 30, 2026
14de717
fix(ci): key trigger lanes per (repo, ref)
aliasfoxkde Oct 30, 2026
8d1230f
Merge origin/main (post PR-353) into local main reconcile
aliasfoxkde Nov 1, 2026
ebe6f3f
Merge remote-tracking branch 'origin/main' into HEAD
aliasfoxkde Nov 2, 2026
ebd0566
docs(planning): record the 2026-11-01 API authorization audit
aliasfoxkde Nov 2, 2026
aeb53b3
test(ci): deliver change-env to skip_if as values, never script text
aliasfoxkde Nov 3, 2026
607667e
fix(api): gate runner inventory reads to maintainer/admin (F3)
aliasfoxkde Nov 3, 2026
45547d4
fix(api): gate runner reads to maintainer/admin roles
aliasfoxkde Nov 3, 2026
346a86b
fix(ci): thread pipeline id through trigger delegation (G1/G2)
aliasfoxkde Nov 3, 2026
1e0eea7
test(db): regression for per-pipeline trigger dedup scoping
aliasfoxkde Nov 3, 2026
e305e92
docs(ci): record 2026-11-02 queue incident and supersede gap
aliasfoxkde Nov 2, 2026
a57f563
fix(api): treat blank pipeline timestamps as null
aliasfoxkde Nov 2, 2026
e905674
fix(sandbox): cap docker CPU quota by cores
aliasfoxkde Nov 2, 2026
ee3809b
feat(ci): newest-SHA-wins supersede for stale branch runs
aliasfoxkde Nov 3, 2026
f6829c0
feat(observability): refresh entity gauges from durable rows
aliasfoxkde Nov 3, 2026
b50fc71
feat(api): gate public telemetry; per-ref supersede column; audit ver…
aliasfoxkde Nov 3, 2026
298bdae
Merge remote-tracking branch 'origin/main' into integrate/audit-fixes…
aliasfoxkde Nov 3, 2026
3285f1b
test(api): align telemetry gate tests with actual behavior
aliasfoxkde Nov 3, 2026
57e2996
fix(api): route telemetry behind auth unless opted in
aliasfoxkde Nov 3, 2026
b782549
style(api): fix rustfmt drift in telemetry merge
aliasfoxkde Nov 3, 2026
95480b5
fix(api): resolve telemetry routing compile errors
aliasfoxkde Nov 3, 2026
598cdcc
fix(api): mount protected telemetry at top level; serialize tests
aliasfoxkde Nov 3, 2026
f1ebec0
fix(api): drop telemetry env lock before await points
aliasfoxkde Nov 3, 2026
76b99a5
Merge fix/telemetry-route-wiring: telemetry behind auth by default
aliasfoxkde Nov 3, 2026
f1d2df7
Merge origin/main into main: absorb co-tenant telemetry/supersede work
aliasfoxkde Nov 3, 2026
d74a1c7
Merge local/main (202) into main: absorb co-tenant reconcile work
aliasfoxkde Nov 3, 2026
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
2 changes: 1 addition & 1 deletion crates/gitforge-api/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const PUBLIC_PATHS: &[&str] = &["/health", "/metrics", "/swagger-ui", "/api-docs
/// instance the aggregate counters leak activity to anonymous parties, so
/// the default is authenticated access. `/health` stays public either way
/// — a liveness probe carries no instance data (F5).
fn telemetry_is_public(raw: Option<&str>) -> bool {
pub(crate) fn telemetry_is_public(raw: Option<&str>) -> bool {
matches!(raw, Some(v) if v.trim() == "1")
}

Expand Down
37 changes: 29 additions & 8 deletions crates/gitforge-api/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,25 @@ impl ApiServer {
.allow_headers(Any)
};

// Public routes (no auth required)
// Public routes (no auth required). Telemetry surfaces are added
// only when the operator opted in; otherwise they ride behind the
// auth middleware via the protected router (F5).
let telemetry_public = crate::middleware::telemetry_is_public(
std::env::var("GITFORGE_PUBLIC_TELEMETRY").ok().as_deref(),
);
let metrics_router = Router::new().route("/metrics", get(metrics_handler));
let health = Router::new().route("/health", get(health_check));
let metrics = Router::new().route("/metrics", get(metrics_handler));
let swagger = Router::new()
.route("/swagger-ui", get(crate::openapi::swagger_ui))
.route("/api-docs/openapi.json", get(crate::openapi::openapi_spec));
let dashboard = crate::dashboard::dashboard_routes();

let mut public_routes = Router::new();
public_routes = public_routes.merge(health);
public_routes = public_routes.merge(metrics);
public_routes = public_routes.merge(dashboard);
public_routes = public_routes.merge(swagger);
if telemetry_public {
let dashboard = crate::dashboard::dashboard_routes();
public_routes = public_routes.merge(metrics_router.clone()).merge(dashboard);
}
// Runner registration is unauthenticated by design (the runner
// proves itself with its registration token inside the handler), so
// it is the other public write surface and gets its own rate-limit
Expand Down Expand Up @@ -159,7 +165,8 @@ impl ApiServer {
.merge(ssh_key_routes())
.merge(artifact_routes())
.merge(webhook_routes())
.merge(review_routes())
.merge(review_routes());
let protected_routes = protected_routes
// Authenticate once at the protected-route boundary. Individual
// handlers may still apply resource authorization, but token
// parsing and validation must not depend on every handler
Expand All @@ -168,16 +175,30 @@ impl ApiServer {
.layer(Extension(Arc::new(auth.clone())))
.layer(Extension(pool_arc.clone()));

// Metrics layer for automatic request recording
// Telemetry surfaces stay at top level either way; when the
// operator has not opted into public access they are merged in
// behind their own auth-middleware layer (F5). They cannot ride
// the /api-nested protected router — nesting would move them to
// /api/metrics and leave the canonical paths unrouted.
let metrics_layer = MetricsLayer::new(metrics_arc.clone());

let app = public_routes
.layer(cors)
.layer(metrics_layer)
.layer(Extension(pool_arc.clone()))
.layer(Extension(metrics_arc))
.merge(auth_routes)
.nest("/api", protected_routes);
let app = if telemetry_public {
app
} else {
let dashboard = crate::dashboard::dashboard_routes();
let telemetry = metrics_router
.merge(dashboard)
.layer(middleware::from_fn(crate::middleware::auth_middleware))
.layer(Extension(Arc::new(auth.clone())))
.layer(Extension(pool_arc));
app.merge(telemetry)
};

Self {
router: app,
Expand Down
43 changes: 42 additions & 1 deletion crates/gitforge-api/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ use gitforge_storage::{Artifact, ArtifactStore};
use std::sync::Arc;
use tower::ServiceExt;

/// Serializes the tests that mutate GITFORGE_PUBLIC_TELEMETRY. The
/// decision is read at router-build time from the process env, which is
/// shared by every test thread in this binary — without the lock the
/// default-posture tests race the opt-in test and randomly invert.
static TELEMETRY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

async fn webhook_fixture(
config: serde_json::Value,
) -> (
Expand Down Expand Up @@ -142,9 +148,16 @@ async fn test_health_check_endpoint() {

#[tokio::test]
async fn test_metrics_endpoint() {
// Opt-in telemetry: with GITFORGE_PUBLIC_TELEMETRY unset, /metrics is
// served behind the auth middleware (F5) and must reject anonymous
// access. The wiring boundary (route placement, not just the
// middleware decision) is what this test pins.
let pool = Pool::memory().await.unwrap();
let _guard = TELEMETRY_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("GITFORGE_PUBLIC_TELEMETRY") };
let server = ApiServer::new("test-secret", pool);
let app = server.into_router();
drop(_guard);

// Request metrics
let response = app
Expand All @@ -157,7 +170,7 @@ async fn test_metrics_endpoint() {
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
Expand Down Expand Up @@ -675,9 +688,36 @@ async fn test_delete_nonexistent_repo() {

#[tokio::test]
async fn test_dashboard_endpoint() {
// Default posture: authenticated only (F5 wiring boundary).
let pool = Pool::memory().await.unwrap();
let _guard = TELEMETRY_ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("GITFORGE_PUBLIC_TELEMETRY") };
let server = ApiServer::new("test-secret", pool);
let app = server.into_router();
drop(_guard);

let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn test_dashboard_endpoint_public_opt_in() {
// Operator opt-in keeps both telemetry surfaces public.
let pool = Pool::memory().await.unwrap();
let _guard = TELEMETRY_ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("GITFORGE_PUBLIC_TELEMETRY", "1") };
let server = ApiServer::new("test-secret", pool);
let app = server.into_router();
drop(_guard);

let response = app
.oneshot(
Expand All @@ -690,6 +730,7 @@ async fn test_dashboard_endpoint() {
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
unsafe { std::env::remove_var("GITFORGE_PUBLIC_TELEMETRY") };
}

#[tokio::test]
Expand Down
Loading