mr-668: GET /graphs endpoint + per-graph policy wire-up (PR 6b/10)

PR 6b of the MR-668 multi-graph server work. First management endpoint —
`GET /graphs` lists every graph registered with the server, gated by the
server-level Cedar policy from PR 6a.

New API shapes (in `omnigraph-server::api`):
  - `GraphInfo { graph_id, uri }` — one entry per registered graph.
  - `GraphListResponse { graphs: Vec<GraphInfo> }` — sorted alphabetically
    by `graph_id` for deterministic output.

Handler `server_graphs_list`:
  - Mounted at `GET /graphs` in both modes.
  - Single mode: returns 405 (resource exists in the API surface, just
    not operational without a `graphs:` map). 405 chosen over 404 so
    clients see "resource exists, wrong context" rather than "no such
    resource".
  - Multi mode: requires bearer auth (when configured); Cedar-gated by
    `PolicyAction::GraphList` against `Omnigraph::Server::"root"`
    (PR 6a's chassis). Returns the sorted registry list.

Cedar gate composition:
  - When no `server.policy.file` is configured, the MR-723 default-deny
    falls through: `GraphList` is not `Read`, so an authenticated actor
    without a server policy gets 403. This is the right default — don't
    expose the registry until the operator explicitly authorizes it.
  - When a server policy is configured, Cedar evaluates the rule. The
    test `get_graphs_with_server_policy_authorizes_per_cedar` pins the
    admin-allow / viewer-deny split.

Routing:
  - New `management` sub-router holding `/graphs` (auth-required, no
    `resolve_graph_handle` middleware — operates on the registry, not
    a single graph).
  - Single mode merges flat protected routes + management.
  - Multi mode merges nested `/graphs/{graph_id}/...` + management.

OpenAPI:
  - `server_graphs_list` registered in `ApiDoc::paths(...)`.
  - `EXPECTED_PATHS` in `tests/openapi.rs` gains `/graphs`.
  - `openapi.json` regenerated (auto-tracked by
    `openapi_spec_is_up_to_date` in CI).

Tests: 4 new in `tests/server.rs::multi_graph_startup`:
  - `get_graphs_lists_registered_graphs_in_multi_mode`
  - `get_graphs_returns_405_in_single_mode`
  - `get_graphs_requires_bearer_auth_when_configured`
  - `get_graphs_with_server_policy_authorizes_per_cedar`

What's NOT in this PR (deferred):
  - Per-graph policy enforcement is wired through `handle.policy`
    (PR 4a already did this); PR 6b doesn't add new per-graph
    behavior beyond making sure the server policy lookup composes
    cleanly alongside it.
  - `POST /graphs` (PR 7) and `DELETE /graphs/{id}` (out of scope
    for v0.7.0).
  - CLI `omnigraph graphs list` (PR 8 will add).

Result: 215 server tests green (74 lib + 66 openapi + 75 integration),
11 policy tests green. MR-731 spoof regression preserved across all
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-25 20:24:52 +02:00
parent 0e5aa036f4
commit 94b6346bdd
No known key found for this signature in database
5 changed files with 392 additions and 6 deletions

View file

@ -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<GraphInfo>,
}

View file

@ -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<AppState> = 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<HealthOutput> {
})
}
#[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<AppState>,
actor: Option<Extension<ResolvedActor>>,
) -> std::result::Result<Json<GraphListResponse>, 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<GraphInfo> = 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<AppState>) -> Json<utoipa::openapi::OpenApi> {
let mut doc = ApiDoc::openapi();
if !state.requires_bearer_auth() {