Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ template-parts/rust/target/

# ─── Rust ─────────────────────────────────────────────────────────────────────
target/

# Python bytecode from the script test harness
__pycache__/
target/quality/

# ─── Coverage & Profiling ───────────────────────────────────────────────────────────
Expand Down
67 changes: 66 additions & 1 deletion crates/gitforge-api/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ use std::sync::Arc;
/// Paths that don't require authentication
const PUBLIC_PATHS: &[&str] = &["/health", "/metrics", "/swagger-ui", "/api-docs", "/docs"];

/// Telemetry paths (`/metrics`, `/dashboard`) are public only when the
/// operator opted in: `GITFORGE_PUBLIC_TELEMETRY=1`. On a multi-tenant
/// 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 {
matches!(raw, Some(v) if v.trim() == "1")
}

/// Paths that are public only when GITFORGE_PUBLIC_TELEMETRY=1.
const TELEMETRY_PATHS: &[&str] = &["/metrics", "/dashboard"];

/// Error response for auth failures
#[derive(Debug, serde::Serialize)]
pub struct AuthErrorResponse {
Expand All @@ -30,9 +42,15 @@ pub async fn auth_middleware(
) -> Response {
let path = request.uri().path().to_string();

// Skip auth for public paths
// Skip auth for public paths. Telemetry paths fall back to
// authenticated access unless GITFORGE_PUBLIC_TELEMETRY=1.
let telemetry_public =
telemetry_is_public(std::env::var("GITFORGE_PUBLIC_TELEMETRY").ok().as_deref());
for public_path in PUBLIC_PATHS {
if path.starts_with(public_path) {
if !telemetry_public && TELEMETRY_PATHS.iter().any(|t| path.starts_with(t)) {
break;
}
return next.run(request).await;
}
}
Expand Down Expand Up @@ -144,6 +162,28 @@ mod tests {
assert!(!PUBLIC_PATHS.iter().any(|p| "/repos".starts_with(p)));
}

#[test]
fn test_telemetry_publicity_gate() {
// Decision core: explicit opt-in only; unset, empty, or any other
// value keeps /metrics and /dashboard behind authentication (F5).
assert!(telemetry_is_public(Some("1")));
assert!(!telemetry_is_public(Some("0")));
assert!(!telemetry_is_public(Some("true")));
assert!(!telemetry_is_public(Some("")));
assert!(telemetry_is_public(Some(" 1 "))); // trim-tolerant opt-in
assert!(!telemetry_is_public(None));
}

#[test]
fn test_telemetry_paths_cover_both_surfaces() {
// /dashboard is not in PUBLIC_PATHS (it has its own route merge),
// so the authenticated fallback must cover it explicitly.
assert!(PUBLIC_PATHS.iter().any(|p| "/metrics".starts_with(p)));
assert!(!PUBLIC_PATHS.iter().any(|p| "/dashboard".starts_with(p)));
assert!(TELEMETRY_PATHS.contains(&"/dashboard"));
assert!(TELEMETRY_PATHS.contains(&"/metrics"));
}

#[test]
fn test_auth_error_response() {
let response = auth_error_response("test_error", "test message");
Expand Down Expand Up @@ -293,6 +333,31 @@ mod tests {
assert!(PUBLIC_PATHS.contains(&"/docs"));
}

#[test]
fn test_middleware_logic_telemetry_fallback() {
// Replicates the middleware branch: telemetry paths are served
// only when the gate is on; every other public path is always
// served; non-public paths never hit the skip.
let served = |path: &str, telemetry_public: bool| -> bool {
for public_path in PUBLIC_PATHS {
if path.starts_with(public_path) {
if !telemetry_public && TELEMETRY_PATHS.iter().any(|t| path.starts_with(t)) {
break;
}
return true;
}
}
false
};
assert!(served("/health", false));
assert!(served("/swagger-ui", false));
assert!(served("/metrics", true));
// /dashboard is not in PUBLIC_PATHS, so the skip-branch never fires
// for it — it reaches auth regardless of the telemetry gate.
assert!(!served("/dashboard", false));
assert!(!served("/dashboard", true));
}

#[test]
fn test_authenticated_user_type_equivalence() {
let claims = Claims {
Expand Down
18 changes: 15 additions & 3 deletions crates/gitforge-api/src/routes/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub fn ci_routes<S: Clone + Send + Sync + 'static>() -> Router<S> {
.route("/jobs", post(submit_job))
}

fn can_manage_jobs(claims: &Claims) -> bool {
pub(crate) fn can_manage_jobs(claims: &Claims) -> bool {
matches!(claims.role.as_str(), "admin" | "maintainer")
}

Expand Down Expand Up @@ -547,7 +547,13 @@ async fn trigger_pipeline_run(
};

match client
.trigger(pipeline.repo_id, &revision, None, &commit)
.trigger(
pipeline.repo_id,
&revision,
None,
&commit,
Some(pipeline.id),
)
.await
{
Ok(Some(pipeline_run_id)) => (
Expand Down Expand Up @@ -715,7 +721,13 @@ async fn rerun_pipeline_run(
};

match client
.trigger(run.repo_id, &run.commit_hash, None, &commit)
.trigger(
run.repo_id,
&run.commit_hash,
None,
&commit,
Some(run.pipeline_id),
)
.await
{
Ok(Some(pipeline_run_id)) => (
Expand Down
31 changes: 29 additions & 2 deletions crates/gitforge-api/src/routes/runners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,24 @@ pub fn public_runner_routes<S: Clone + Send + Sync + 'static>(
}

/// List runners
///
/// Runner inventory (names, capacity, heartbeat) is operational data; only
/// admin/maintainer roles may read it. Any-authenticated listing let a
/// plain developer enumerate the fleet.
async fn list_runners(
_user: AuthenticatedUser,
user: AuthenticatedUser,
Extension(pool): Extension<Arc<Pool>>,
) -> impl IntoResponse {
if !crate::routes::ci::can_manage_jobs(&user.claims) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "forbidden",
"message": "runner administration requires maintainer role"
})),
)
.into_response();
}
match RunnerQueries::list(&pool).await {
Ok(runners) => {
let response: Vec<RunnerResponse> =
Expand Down Expand Up @@ -214,11 +228,24 @@ async fn register_runner(
}

/// Get runner
///
/// Same role gate as `list_runners` — a single runner's row carries the
/// same operational detail the list does.
async fn get_runner(
_user: AuthenticatedUser,
user: AuthenticatedUser,
Extension(pool): Extension<Arc<Pool>>,
Path(id): Path<String>,
) -> impl IntoResponse {
if !crate::routes::ci::can_manage_jobs(&user.claims) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "forbidden",
"message": "runner administration requires maintainer role"
})),
)
.into_response();
}
tracing::debug!("get runner: {}", id);

match Uuid::parse_str(&id) {
Expand Down
18 changes: 14 additions & 4 deletions crates/gitforge-api/src/routes/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,12 @@ impl CiTriggerClient {
branch: &str,
old_commit_hash: Option<&str>,
commit_hash: &str,
pipeline_id: Option<PipelineId>,
) -> Result<Option<String>, String> {
let old_hash = old_commit_hash
.filter(|hash| !hash.is_empty())
.unwrap_or("0000000000000000000000000000000000000000");
.filter(|hash| !hash.is_empty() && !gitforge_common::is_zero_hash(hash))
// git's fixed ref-deletion sentinel: all-zero SHA-1 length.
.map_or_else(|| "0".repeat(40), str::to_string);
let response = self
.client
// The configured value is validated at startup, but never reaches
Expand All @@ -176,7 +178,13 @@ impl CiTriggerClient {
"ref_name": branch,
"old_hash": old_hash,
"new_hash": commit_hash,
"working_dir": null
"working_dir": null,
// Without the id the consumer falls back to the repo's
// cache/persisted/default definition: two pipelines on one
// commit could not both run, and an API-uploaded config was
// bypassed when the revision had no committed file
// (G1/G2, docs/planning/PRODUCTION_VALIDATION_20261102.md).
"pipeline_id": pipeline_id.map(|id| id.to_string()),
}))
.send()
.await
Expand Down Expand Up @@ -279,6 +287,7 @@ async fn trigger_pipeline(
&payload.branch,
payload.old_commit_hash.as_deref(),
&payload.commit_hash,
None,
)
.await
{
Expand Down Expand Up @@ -358,13 +367,14 @@ async fn trigger_pipeline(

// Persist the run before queueing so scheduler job foreign keys
// and recovery have a durable parent record.
let run = gitforge_db::models::PipelineRun::new(
let mut run = gitforge_db::models::PipelineRun::new(
pipeline.id,
repo_id,
"webhook".to_string(),
payload.commit_hash.clone(),
);
let run_id = run.id;
run.ref_name = Some(payload.branch.clone());
if let Err(error) = PipelineRunQueries::create(&pool, &run).await {
tracing::error!(%error, %pipeline_id, "failed to persist webhook pipeline run");
return (
Expand Down
16 changes: 14 additions & 2 deletions crates/gitforge-api/tests/admin_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,11 @@ async fn runner_listing_requires_auth_and_shows_registered_runners() {
let (status, _) = get_json(f.app.clone(), "/api/runners", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);

let (status, body) = get_json(f.app.clone(), "/api/runners", Some(&f.developer_token)).await;
// Runner inventory is maintainer/admin-only (2026-11-02 authz audit F3).
let (status, _) = get_json(f.app.clone(), "/api/runners", Some(&f.developer_token)).await;
assert_eq!(status, StatusCode::FORBIDDEN);

let (status, body) = get_json(f.app.clone(), "/api/runners", Some(&f.admin_token)).await;
assert_eq!(status, StatusCode::OK);
let runners = body.as_array().unwrap();
assert_eq!(runners.len(), 1);
Expand Down Expand Up @@ -259,12 +263,20 @@ async fn get_runner_validates_the_id_and_missing_rows() {

let runner = Runner::new("visible-runner".to_string(), RunnerType::BareMetal, 8);
RunnerQueries::create(&f.pool, &runner).await.unwrap();
let (status, body) = get_json(
// Single-runner reads carry the same maintainer/admin gate.
let (status, _) = get_json(
f.app.clone(),
&format!("/api/runners/{}", runner.id),
Some(&f.developer_token),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, body) = get_json(
f.app.clone(),
&format!("/api/runners/{}", runner.id),
Some(&f.admin_token),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["type"], "bare_metal");
assert_eq!(body["capacity"], 8);
Expand Down
8 changes: 8 additions & 0 deletions crates/gitforge-api/tests/ci_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1749,4 +1749,12 @@ async fn run_trigger_delegation_ladder_relays_the_orchestrator_answers() {
assert_eq!(seen[0].body["ref_name"], commit);
assert_eq!(seen[0].body["new_hash"], commit);
assert_eq!(seen[0].body["old_hash"], "0".repeat(40));
// G1: the delegated trigger must carry the requested pipeline id, or
// the orchestrator binds the run to whichever definition its cache
// resolves and two pipelines on one commit cannot both run.
assert_eq!(
seen[0].body["pipeline_id"],
pipeline_id.to_string(),
"trigger payload must name the pipeline row being run"
);
}
16 changes: 16 additions & 0 deletions crates/gitforge-api/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@ async fn test_api_artifacts_are_scoped_to_job_repository_owner() {
finished_at: Some(chrono::Utc::now()),
created_at: chrono::Utc::now(),
error: None,
ref_name: None,
},
)
.await
Expand Down Expand Up @@ -1133,6 +1134,11 @@ async fn test_api_runners_endpoint() {
gitforge_db::queries::UserQueries::create(&pool, &user)
.await
.unwrap();
// Runner reads are maintainer/admin-only (2026-11-02 authz audit F3);
// the middleware re-resolves the persisted role, so the row carries it.
gitforge_db::queries::UserQueries::set_role(&pool, user.id, "admin")
.await
.unwrap();

let server = ApiServer::new("test-secret", pool);
let app = server.into_router();
Expand Down Expand Up @@ -1171,6 +1177,10 @@ async fn test_api_runner_registration_adopts_existing_name() {
gitforge_db::queries::UserQueries::create(&pool, &user)
.await
.unwrap();
// Runner reads are maintainer/admin-only (F3); persist the role.
gitforge_db::queries::UserQueries::set_role(&pool, user.id, "admin")
.await
.unwrap();

let server = ApiServer::with_registration_token(
"test-secret",
Expand Down Expand Up @@ -1260,6 +1270,10 @@ async fn test_api_runner_list_derives_status_from_heartbeat() {
gitforge_db::queries::UserQueries::create(&pool, &user)
.await
.unwrap();
// Runner reads are maintainer/admin-only (F3); persist the role.
gitforge_db::queries::UserQueries::set_role(&pool, user.id, "admin")
.await
.unwrap();

// A registry row left online whose heartbeat died long ago — the shape
// a scheduler restart leaves behind.
Expand Down Expand Up @@ -2237,6 +2251,7 @@ async fn test_api_job_control_enforces_repository_ownership_and_persists_cancel(
finished_at: None,
created_at: chrono::Utc::now(),
error: None,
ref_name: None,
},
)
.await
Expand Down Expand Up @@ -2384,6 +2399,7 @@ async fn test_api_pipeline_runs_with_jobs() {
finished_at: None,
created_at: chrono::Utc::now(),
error: None,
ref_name: None,
};
gitforge_db::queries::PipelineRunQueries::create(&pool, &run)
.await
Expand Down
Loading
Loading