diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index 1195f12..5a10152 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -467,3 +467,23 @@ pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput { }, } } + +// ─── MR-668 PR 6b — management endpoint shapes ───────────────────────────── + +/// One entry in the response from `GET /graphs`. Cluster operators +/// consume this list to discover which graphs the server is currently +/// serving. The shape is intentionally minimal — `graph_id` and `uri` +/// are the only fields a routing client needs. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GraphInfo { + pub graph_id: String, + pub uri: String, +} + +/// Response from `GET /graphs`. Lists every graph registered with the +/// server in alphabetical order by `graph_id` (sorted server-side so +/// clients get deterministic output across requests). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GraphListResponse { + pub graphs: Vec, +} diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 217d6de..ed05363 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -21,9 +21,10 @@ use std::sync::Arc; use api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, - CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, HealthOutput, IngestOutput, - IngestRequest, ReadOutput, ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, - SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, + CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, GraphInfo, GraphListResponse, + HealthOutput, IngestOutput, IngestRequest, ReadOutput, ReadRequest, SchemaApplyOutput, + SchemaApplyRequest, SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, + snapshot_payload, }; pub use auth::{AWS_SECRET_ENV, EnvOrFileTokenSource, TokenSource, resolve_token_source}; use axum::body::{Body, Bytes}; @@ -79,6 +80,7 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash { ), paths( server_health, + server_graphs_list, server_snapshot, server_read, server_export, @@ -909,13 +911,27 @@ pub fn build_app(state: AppState) -> Router { require_bearer_auth, )); + // Management endpoints (`GET /graphs`, future `POST /graphs`) + // live alongside the per-graph router. They go through bearer auth + // but NOT through `resolve_graph_handle` — they operate on the + // registry directly. The endpoint is mounted in both modes; in + // single mode the handler returns 405 so clients see "resource + // exists, wrong context" rather than 404 "no such resource." + let management = Router::new() + .route("/graphs", get(server_graphs_list)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )); + // Mount the protected routes differently per mode: // * Single → flat routes (legacy: `/snapshot`, `/read`, etc.) // * Multi → nested under `/graphs/{graph_id}/...` - // Mode is inferred at startup; the same router code branches once. let protected: Router = match state.mode() { - ServerMode::Single { .. } => per_graph_protected, - ServerMode::Multi { .. } => Router::new().nest("/graphs/{graph_id}", per_graph_protected), + ServerMode::Single { .. } => per_graph_protected.merge(management), + ServerMode::Multi { .. } => Router::new() + .nest("/graphs/{graph_id}", per_graph_protected) + .merge(management), }; Router::new() @@ -1108,6 +1124,78 @@ async fn server_health() -> Json { }) } +#[utoipa::path( + get, + path = "/graphs", + tag = "management", + operation_id = "listGraphs", + responses( + (status = 200, description = "List of registered graphs", body = GraphListResponse), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + (status = 405, description = "Method not allowed (single-graph mode)", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +/// List every graph currently registered with this server (MR-668). +/// +/// Multi-graph mode only. In single mode, the route returns 405 — there's +/// no registry to enumerate. Cedar-gated by the server-level policy via +/// the `graph_list` action against `Omnigraph::Server::"root"`. +/// +/// Order: alphabetical by `graph_id` (server-sorted so clients see +/// deterministic output across requests). +async fn server_graphs_list( + State(state): State, + actor: Option>, +) -> std::result::Result, ApiError> { + // 405 in single mode — there's no registry to enumerate, and the + // legacy URL surface didn't expose this endpoint. + if matches!(state.mode(), ServerMode::Single { .. }) { + return Err(ApiError { + status: StatusCode::METHOD_NOT_ALLOWED, + code: ErrorCode::BadRequest, + message: "GET /graphs is only available in multi-graph mode".to_string(), + merge_conflicts: Vec::new(), + manifest_conflict: None, + }); + } + + // Server-level Cedar gate. `state.server_policy` is loaded from + // `server.policy.file` in `omnigraph.yaml` at startup. When no + // server policy is configured, `authorize_request_server` falls + // through to the MR-723 default-deny semantics (every non-Read + // action denied for an authenticated actor). `GraphList` is not + // `Read`, so without a server policy the request gets 403 — which + // is the right default (don't leak the registry until the operator + // explicitly authorizes it). + authorize_request( + actor.as_ref().map(|Extension(actor)| actor), + state.server_policy.as_deref(), + PolicyRequest { + actor_id: actor + .as_ref() + .map(|Extension(actor)| actor.actor_id.as_ref().to_string()) + .unwrap_or_default(), + action: PolicyAction::GraphList, + branch: None, + target_branch: None, + }, + )?; + + let mut graphs: Vec = state + .registry() + .list() + .into_iter() + .map(|handle| GraphInfo { + graph_id: handle.key.graph_id.as_str().to_string(), + uri: handle.uri.clone(), + }) + .collect(); + graphs.sort_by(|a, b| a.graph_id.cmp(&b.graph_id)); + Ok(Json(GraphListResponse { graphs })) +} + async fn server_openapi(State(state): State) -> Json { let mut doc = ApiDoc::openapi(); if !state.requires_bearer_auth() { diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index 0e70f3b..b4f96df 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -161,6 +161,7 @@ fn openapi_info_contains_version() { const EXPECTED_PATHS: &[&str] = &[ "/healthz", + "/graphs", "/snapshot", "/read", "/export", diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index dcd47b2..ed5088d 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -4675,6 +4675,195 @@ graphs: } } + /// `GET /graphs` lists the registered graphs alphabetically in + /// multi mode. No auth or server policy = open mode (allowed). + #[tokio::test(flavor = "multi_thread")] + async fn get_graphs_lists_registered_graphs_in_multi_mode() { + let (_dirs, app) = build_multi_mode_app(&["beta", "alpha"]).await; + let resp = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + let graphs = json["graphs"].as_array().unwrap(); + assert_eq!(graphs.len(), 2); + // Server-sorted alphabetically. + assert_eq!(graphs[0]["graph_id"].as_str().unwrap(), "alpha"); + assert_eq!(graphs[1]["graph_id"].as_str().unwrap(), "beta"); + } + + /// `GET /graphs` returns 405 in single mode (resource exists in the + /// API surface, just not operational without a `graphs:` map). + #[tokio::test(flavor = "multi_thread")] + async fn get_graphs_returns_405_in_single_mode() { + let temp = init_loaded_graph().await; + let graph = graph_path(temp.path()); + let state = AppState::open(graph.to_string_lossy().to_string()) + .await + .unwrap(); + let app = build_app(state); + let resp = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + /// `GET /graphs` requires bearer auth when tokens are configured. + #[tokio::test(flavor = "multi_thread")] + async fn get_graphs_requires_bearer_auth_when_configured() { + use omnigraph_server::{GraphHandle, GraphId, GraphKey}; + // Build a multi-mode app with bearer tokens configured. + let dir = tempfile::tempdir().unwrap(); + let graph_uri = dir.path().join("alpha").to_str().unwrap().to_string(); + let schema = fs::read_to_string(fixture("test.pg")).unwrap(); + let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap(); + let handle = Arc::new(GraphHandle { + key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()), + uri: graph_uri, + engine: Arc::new(engine), + policy: None, + }); + let tokens = vec![("act-andrew".to_string(), "secret-token".to_string())]; + let workload = omnigraph_server::workload::WorkloadController::from_env(); + let state = + AppState::new_multi(vec![handle], tokens, None, workload, None).unwrap(); + let app = build_app(state); + + // No Authorization header → 401. + let resp_no_auth = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp_no_auth.status(), StatusCode::UNAUTHORIZED); + + // With auth but no server policy → 403 (default-deny, since + // GraphList is not Read). + let resp_authed = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .header("authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp_authed.status(), StatusCode::FORBIDDEN); + } + + /// `GET /graphs` with a server policy that allows `graph_list` → 200. + /// `GET /graphs` with a server policy that does NOT allow `graph_list` → 403. + #[tokio::test(flavor = "multi_thread")] + async fn get_graphs_with_server_policy_authorizes_per_cedar() { + use omnigraph_policy::PolicyEngine; + use omnigraph_server::{GraphHandle, GraphId, GraphKey}; + + let dir = tempfile::tempdir().unwrap(); + let graph_uri = dir.path().join("alpha").to_str().unwrap().to_string(); + let schema = fs::read_to_string(fixture("test.pg")).unwrap(); + let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap(); + let handle = Arc::new(GraphHandle { + key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()), + uri: graph_uri, + engine: Arc::new(engine), + policy: None, + }); + + // Server policy: admins can graph_list, viewers cannot. + let policy_path = dir.path().join("server-policy.yaml"); + fs::write( + &policy_path, + r#" +version: 1 +groups: + admins: [act-andrew] + viewers: [act-bruno] +rules: + - id: admins-list-graphs + allow: + actors: { group: admins } + actions: [graph_list] +"#, + ) + .unwrap(); + let server_policy = PolicyEngine::load(&policy_path, "server").unwrap(); + + let tokens = vec![ + ("act-andrew".to_string(), "andrew-token".to_string()), + ("act-bruno".to_string(), "bruno-token".to_string()), + ]; + let workload = omnigraph_server::workload::WorkloadController::from_env(); + let state = AppState::new_multi( + vec![handle], + tokens, + Some(server_policy), + workload, + None, + ) + .unwrap(); + let app = build_app(state); + + // Admin → 200 + let resp_admin = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .header("authorization", "Bearer andrew-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp_admin.status(), + StatusCode::OK, + "admin must be allowed graph_list" + ); + + // Viewer → 403 + let resp_viewer = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/graphs") + .header("authorization", "Bearer bruno-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp_viewer.status(), + StatusCode::FORBIDDEN, + "viewer must be denied graph_list (Cedar gate)" + ); + } + /// End-to-end: load an `omnigraph.yaml` with two graphs and serve /// them. Both graphs must be queryable via cluster routes. /// diff --git a/openapi.json b/openapi.json index 75c9379..8c5abc9 100644 --- a/openapi.json +++ b/openapi.json @@ -585,6 +585,63 @@ ] } }, + "/graphs": { + "get": { + "tags": [ + "management" + ], + "summary": "List every graph currently registered with this server (MR-668).", + "description": "Multi-graph mode only. In single mode, the route returns 405 — there's\nno registry to enumerate. Cedar-gated by the server-level policy via\nthe `graph_list` action against `Omnigraph::Server::\"root\"`.\n\nOrder: alphabetical by `graph_id` (server-sorted so clients see\ndeterministic output across requests).", + "operationId": "listGraphs", + "responses": { + "200": { + "description": "List of registered graphs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "405": { + "description": "Method not allowed (single-graph mode)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, "/healthz": { "get": { "tags": [ @@ -1268,6 +1325,37 @@ } } }, + "GraphInfo": { + "type": "object", + "description": "One entry in the response from `GET /graphs`. Cluster operators\nconsume this list to discover which graphs the server is currently\nserving. The shape is intentionally minimal — `graph_id` and `uri`\nare the only fields a routing client needs.", + "required": [ + "graph_id", + "uri" + ], + "properties": { + "graph_id": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + "GraphListResponse": { + "type": "object", + "description": "Response from `GET /graphs`. Lists every graph registered with the\nserver in alphabetical order by `graph_id` (sorted server-side so\nclients get deterministic output across requests).", + "required": [ + "graphs" + ], + "properties": { + "graphs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GraphInfo" + } + } + } + }, "HealthOutput": { "type": "object", "required": [