mr-668: server-scoped actions always require explicit policy (green)

`server_graphs_list` returned 200 in Open mode (`--unauthenticated`,
no tokens, no policy) because `authorize_request`'s no-policy
fallback only denied when `actor.is_some()` AND action != Read.
In Open mode `actor: None`, so the denial branch never fired and
the call returned `Ok(())` — leaking the registry (graph IDs +
URIs that may contain S3 bucket paths or internal hostnames) to
any unauthenticated caller. The docstring on `server_graphs_list`
claimed it was "Cedar-gated" and that the server should "not leak
the registry until the operator explicitly authorizes it" —
docstring intent and code disagreed.

Symptomatic fix: special-case GraphList. Breaks the moment
another server-scoped action (`graph_create`, `graph_delete`) is
added.

Correct-by-design fix: tie authorization to the action's
`resource_kind()`. Server-scoped actions
(`PolicyResourceKind::Server`) always require explicit policy
authorization — there is no runtime state where they're served
by default. Per-graph actions keep the existing default-deny
logic (DefaultDeny denies non-Read for authenticated actors;
Open mode allows everything per the operator's `--unauthenticated`
opt-in for graph DATA, but not for server topology).

The fix uses the existing `PolicyResourceKind` enum that #119
already added — no new abstraction. Future server-scoped actions
(runtime `graph_create`/`graph_delete` when the cluster catalog
ships) automatically pick up the same enforcement without any
per-action handler change.

Changes:

* `crates/omnigraph-server/src/lib.rs:51` — re-export
  `PolicyResourceKind` (the kind discriminator was already public
  on the omnigraph-policy crate; needed in scope here).
* `crates/omnigraph-server/src/lib.rs:1457` — `authorize_request`'s
  no-policy fallback gains a server-scoped-action check that fires
  before the actor-based default-deny logic. Error message names
  the failure mode and points at `server.policy.file`.
* `crates/omnigraph-server/tests/server.rs:5037` —
  `get_graphs_with_server_policy_authorizes_per_cedar` extended
  to register two graphs in non-alphabetical order and assert
  the admin-200 response is sorted alphabetically. Restores the
  sort-order coverage that lived in
  `get_graphs_lists_registered_graphs_in_multi_mode` before the
  red commit renamed it to assert denial.

Also bundles a small adjacent cleanup that the bot-review flagged:

* `crates/omnigraph-server/src/graph_id.rs:124` — drop the
  unreachable `"openapi.json"` entry from `is_reserved`. The
  regex `^[a-zA-Z0-9-]{1,64}$` rejects every dot-containing name
  before `is_reserved` can run, so dotted entries in this list
  were dead code that misled readers into thinking the list
  needed to cover them. Comment now names the structural
  exclusion. The `rejects_reserved_route_names` test loses its
  `openapi.json` row (covered by `rejects_dots` via the regex).

Closes the "server-scoped management actions silently leak in
Open mode" class. Red test from the previous commit
(`get_graphs_denied_in_open_mode_without_server_policy`) turns
green; all 78 server integration tests + 76 lib tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-28 11:27:01 +02:00
parent c5a6a7125a
commit 65e53933fb
No known key found for this signature in database
3 changed files with 83 additions and 38 deletions

View file

@ -5034,23 +5034,39 @@ graphs:
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.
/// `GET /graphs` with a server policy that allows `graph_list` → 200
/// and returns the registry sorted alphabetically by `graph_id`.
/// `GET /graphs` with a server policy that does NOT allow
/// `graph_list` (viewer group) → 403.
///
/// This test owns the alphabetical-sort coverage that previously
/// lived in `get_graphs_lists_registered_graphs_in_multi_mode`.
/// That test now asserts denial in Open mode (server-scoped actions
/// require explicit policy in every runtime state), so the positive
/// body-shape assertions need a home where the response is
/// operator-authorized — here.
#[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();
// Two graphs deliberately registered in non-alphabetical order
// so the test would fail if the handler relied on insertion
// order instead of server-side sorting.
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 mut handles = Vec::new();
for id in ["beta", "alpha"] {
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
handles.push(Arc::new(GraphHandle {
key: GraphKey::cluster(GraphId::try_from(id).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");
@ -5076,17 +5092,11 @@ rules:
("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 state = AppState::new_multi(handles, tokens, Some(server_policy), workload, None)
.unwrap();
let app = build_app(state);
// Admin → 200
// Admin → 200, body returns both graphs alphabetically sorted.
let resp_admin = app
.clone()
.oneshot(
@ -5104,6 +5114,16 @@ rules:
StatusCode::OK,
"admin must be allowed graph_list"
);
let body = to_bytes(resp_admin.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, "response must list both registered graphs");
assert_eq!(
graphs[0]["graph_id"].as_str().unwrap(),
"alpha",
"server must sort graphs alphabetically by graph_id (insertion order was 'beta', 'alpha')"
);
assert_eq!(graphs[1]["graph_id"].as_str().unwrap(), "beta");
// Viewer → 403
let resp_viewer = app