mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
[codex] fix RFC-011 follow-up regressions (#258)
* fix rfc-011 follow-up regressions
* test(cli): remove served schema-apply tests obsoleted by the cluster 409
This PR disables server-side schema apply for cluster-backed serving (409 →
`omnigraph cluster apply`). Two system_local tests still drove *served* schema
apply against a spawned `--cluster` server and asserted the pre-409 behavior, so
they failed under `cargo test --workspace`:
- `local_cli_schema_apply_enforces_engine_layer_policy` — expected a per-actor
policy `denied`/allow on the served route; the route now 409s for everyone
before policy runs.
- `local_cli_schema_apply_rejects_stored_query_breakage_before_publish` —
expected a served apply to reject a stored-query breakage; the route now 409s
before any apply.
Both exercise a path the PR intentionally removed. Their surviving coverage:
the 409 itself is pinned by `schema_routes::schema_apply_route_refuses_cluster_backed_server_mode`
(asserts 409 + no mutation); stored-query-breakage-before-publish stays covered
by `schema_routes::schema_apply_route_rejects_stored_query_breakage_before_publish`
(single-mode); engine-layer schema_apply Cedar enforcement stays covered by
`policy_engine_chassis`. Remove the obsolete served versions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(server): report the cluster-backed schema-apply 409 after the Cedar gate
The 409 ("schema apply is disabled for cluster-backed serving") fired at the top
of `server_schema_apply`, before `authorize_request`. An authenticated-but-
unauthorized actor therefore learned the server is cluster-backed (409) instead
of getting a normal 403 — leaking topology before authorization, against the
same posture that keeps `GET /graphs` default-deny.
Move the 409 below the Cedar gate so the route reports 401 → 403 → 409: an
unauthorized actor gets 403, and only an actor authorized for `schema_apply`
sees the actionable "use `omnigraph cluster apply`" 409. (An open/unauthenticated
server still 409s, as it has no topology to protect.)
Regression: `schema_apply_route_cluster_backed_denies_unauthorized_actor_before_409`
(POLICY_YAML grants no schema_apply → act-ragnor gets 403, not 409). Addresses the
bot-review finding on #258.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9513b076d2
commit
b5658dc696
19 changed files with 429 additions and 261 deletions
|
|
@ -180,7 +180,7 @@ Rust stable workspace (edition 2024). `protoc` is a build dependency (`brew inst
|
|||
cargo build --workspace --locked # build everything
|
||||
cargo test --workspace --locked # the canonical CI gate (matches CI exactly)
|
||||
cargo run -p omnigraph-cli -- <args> # run the `omnigraph` CLI from source
|
||||
cargo run -p omnigraph-server -- <uri> --bind 0.0.0.0:8080 # run the server from source
|
||||
cargo run -p omnigraph-server -- --cluster <dir|s3://...> --bind 0.0.0.0:8080 # run the server from source
|
||||
|
||||
# Run one crate / one test file / one test fn
|
||||
cargo test -p omnigraph-engine --test traversal # one integration-test file (see docs/dev/testing.md)
|
||||
|
|
@ -232,10 +232,10 @@ omnigraph cleanup --keep 10 --older-than 7d --confirm s3://my-bucket/graph.omni
|
|||
|
||||
# Stand up the HTTP server (token from env)
|
||||
OMNIGRAPH_SERVER_BEARER_TOKEN=xxxx \
|
||||
omnigraph-server s3://my-bucket/graph.omni --bind 0.0.0.0:8080
|
||||
omnigraph-server --cluster s3://my-bucket/cluster --bind 0.0.0.0:8080
|
||||
|
||||
# Cedar policy explain
|
||||
omnigraph policy explain --actor act-alice --action change --branch main
|
||||
omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act-alice --action change --branch main
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ direct — direct storage access; reject --server (init, optimize, repair, clean
|
|||
schema plan, lint).\n \
|
||||
control — manage or inspect a cluster (cluster via --config; policy & queries via \
|
||||
--cluster).\n \
|
||||
local — no graph; local config & tooling: embed, login, logout, profile, version.\n\
|
||||
local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\
|
||||
See the 'Command capabilities' section of the CLI reference for which flags apply where.")]
|
||||
pub(crate) struct Cli {
|
||||
/// Actor id for direct-engine writes; overrides `cli.actor`. No effect on
|
||||
|
|
@ -325,8 +325,7 @@ pub(crate) enum Command {
|
|||
command: ClusterCommand,
|
||||
},
|
||||
|
||||
// ── Session / config ── no graph addressing; local tooling.
|
||||
/// Policy administration and diagnostics
|
||||
/// Policy administration and diagnostics against a cluster's applied bundles
|
||||
Policy {
|
||||
#[command(subcommand)]
|
||||
command: PolicyCommand,
|
||||
|
|
|
|||
|
|
@ -367,12 +367,16 @@ pub(crate) async fn execute_operator_alias(
|
|||
}
|
||||
}
|
||||
|
||||
let body = (!params.is_empty()).then(|| serde_json::json!({ "params": params }));
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("expect_mutation".to_string(), Value::Bool(false));
|
||||
if !params.is_empty() {
|
||||
body.insert("params".to_string(), Value::Object(params));
|
||||
}
|
||||
remote_json(
|
||||
client,
|
||||
Method::POST,
|
||||
remote_url(&uri, &["queries", &alias.query], &[])?,
|
||||
body,
|
||||
Some(Value::Object(body)),
|
||||
bearer_token.as_deref(),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -106,15 +106,44 @@ async fn main() -> Result<()> {
|
|||
.profiles
|
||||
.iter()
|
||||
.map(|(name, profile)| {
|
||||
let binding = match profile.binding(name) {
|
||||
Ok(ScopeBinding::Server(s)) => format!("server: {s}"),
|
||||
Ok(ScopeBinding::Cluster(c)) => format!("cluster: {c}"),
|
||||
Ok(ScopeBinding::Store(u)) => format!("store: {u}"),
|
||||
Err(e) => format!("invalid: {e}"),
|
||||
};
|
||||
let (binding, scope_kind, target, valid, error) =
|
||||
match profile.binding(name) {
|
||||
Ok(ScopeBinding::Server(s)) => (
|
||||
format!("server: {s}"),
|
||||
"server".to_string(),
|
||||
Some(s),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
Ok(ScopeBinding::Cluster(c)) => (
|
||||
format!("cluster: {c}"),
|
||||
"cluster".to_string(),
|
||||
Some(c),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
Ok(ScopeBinding::Store(u)) => (
|
||||
format!("store: {u}"),
|
||||
"store".to_string(),
|
||||
Some(u),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
Err(e) => (
|
||||
format!("invalid: {e}"),
|
||||
"invalid".to_string(),
|
||||
None,
|
||||
false,
|
||||
Some(e.to_string()),
|
||||
),
|
||||
};
|
||||
ProfileListItem {
|
||||
name: name.clone(),
|
||||
binding,
|
||||
scope_kind,
|
||||
target,
|
||||
valid,
|
||||
error,
|
||||
default_graph: profile.default_graph.clone(),
|
||||
active: active.as_deref() == Some(name.as_str()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -892,6 +892,12 @@ pub(crate) struct ProfileListItem {
|
|||
pub(crate) name: String,
|
||||
/// `server: <n>` / `cluster: <n>` / `store: <uri>` / `invalid: <reason>`.
|
||||
pub(crate) binding: String,
|
||||
/// `server` | `cluster` | `store` | `invalid`.
|
||||
pub(crate) scope_kind: String,
|
||||
/// The bound server/cluster name, or the store URI. `None` when invalid.
|
||||
pub(crate) target: Option<String>,
|
||||
pub(crate) valid: bool,
|
||||
pub(crate) error: Option<String>,
|
||||
pub(crate) default_graph: Option<String>,
|
||||
pub(crate) active: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,13 +98,11 @@ pub(crate) fn command_capability(cmd: &Command) -> Capability {
|
|||
/// The plane a subcommand belongs to. Exhaustive — a new `Command` variant
|
||||
/// will not compile until classified. Descends into the nested enums where
|
||||
/// the plane differs per subcommand (`schema plan` is storage while `schema
|
||||
/// show`/`apply` are data; `queries validate` opens the graph while `queries
|
||||
/// list` only reads config).
|
||||
/// show`/`apply` are data; `queries`/`policy` read cluster applied state).
|
||||
pub(crate) fn command_plane(cmd: &Command) -> Plane {
|
||||
match cmd {
|
||||
Command::Query { .. }
|
||||
| Command::Mutate { .. }
|
||||
| Command::Alias { .. }
|
||||
| Command::Load { .. }
|
||||
| Command::Ingest { .. }
|
||||
| Command::Branch { .. }
|
||||
|
|
@ -129,7 +127,8 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane {
|
|||
| Command::Cleanup { .. }
|
||||
| Command::Lint { .. } => Plane::Storage,
|
||||
Command::Cluster { .. } => Plane::Control,
|
||||
Command::Embed(_)
|
||||
Command::Alias { .. }
|
||||
| Command::Embed(_)
|
||||
| Command::Login { .. }
|
||||
| Command::Logout { .. }
|
||||
| Command::Profile { .. }
|
||||
|
|
@ -175,12 +174,13 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The verbs that address an existing graph through a cluster scope
|
||||
/// (`--cluster <root> --graph <id>`): the storage-maintenance commands.
|
||||
/// `init` is storage-plane too but *creates* a graph (cluster graphs are born
|
||||
/// from `cluster apply`, not `init`), and `schema plan` / `lint` take a
|
||||
/// positional URI — none consume cluster addressing, so the guard rejects
|
||||
/// `--cluster`/`--graph` on them rather than silently dropping the flag.
|
||||
/// The verbs that consume a cluster scope. Maintenance/lint select a graph with
|
||||
/// `--cluster <root> --graph <id>`; policy/queries inspect the cluster's
|
||||
/// applied control-plane state and may optionally use `--graph` to select one
|
||||
/// bundle/registry. `init` is storage-plane too but *creates* a graph (cluster
|
||||
/// graphs are born from `cluster apply`, not `init`), and `schema plan` takes a
|
||||
/// positional URI, so the guard rejects `--cluster`/`--graph` there rather than
|
||||
/// silently dropping the flag.
|
||||
pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
|
|
@ -201,12 +201,43 @@ pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool {
|
|||
/// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb
|
||||
/// that cannot consume it, rather than silently dropping it (the old behavior:
|
||||
/// e.g. `optimize --server prod` dropped `--server` and failed later with an
|
||||
/// unrelated message). Each flag has a distinct valid surface:
|
||||
/// unrelated message). `alias` gets an extra guard because its binding owns all
|
||||
/// addressing and several ignored globals sit outside this three-flag guard.
|
||||
/// Each flag has a distinct valid surface:
|
||||
/// - `--server` → served-graph scopes (`any`/`served`);
|
||||
/// - `--cluster` → the cluster-maintenance verbs (optimize/repair/cleanup);
|
||||
/// - `--cluster` → cluster-scoped direct/control verbs;
|
||||
/// - `--graph` → any multi-graph scope: a served scope *or* a cluster one.
|
||||
/// RFC-010 Slice 1, generalized for RFC-011 cluster addressing.
|
||||
pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> {
|
||||
if let Command::Alias { .. } = &cli.command {
|
||||
let mut flags = Vec::new();
|
||||
if cli.server.is_some() {
|
||||
flags.push("--server");
|
||||
}
|
||||
if cli.graph.is_some() {
|
||||
flags.push("--graph");
|
||||
}
|
||||
if cli.store.is_some() {
|
||||
flags.push("--store");
|
||||
}
|
||||
if cli.cluster.is_some() {
|
||||
flags.push("--cluster");
|
||||
}
|
||||
if cli.profile.is_some() {
|
||||
flags.push("--profile");
|
||||
}
|
||||
if cli.as_actor.is_some() {
|
||||
flags.push("--as");
|
||||
}
|
||||
if !flags.is_empty() {
|
||||
bail!(
|
||||
"`alias` uses the server, graph, and stored query declared in \
|
||||
`aliases.<name>` in ~/.omnigraph/config.yaml; remove global scope \
|
||||
flag(s): {}",
|
||||
flags.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -223,8 +254,8 @@ pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> {
|
|||
}
|
||||
if cli.cluster.is_some() && !cluster_ok {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --cluster addresses a cluster-managed graph for \
|
||||
maintenance (optimize/repair/cleanup) and does not apply.{}",
|
||||
"`{label}` is a {} command; --cluster addresses a cluster-scoped command \
|
||||
and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
|
|
@ -253,7 +284,15 @@ fn remediation(capability: Capability, cmd: &Command) -> &'static str {
|
|||
}
|
||||
_ => " Pass a storage URI.",
|
||||
},
|
||||
Capability::Control => " It operates on a cluster (pass --config <dir>).",
|
||||
Capability::Control => match cmd {
|
||||
Command::Cluster { .. } => {
|
||||
" It operates on a cluster config directory (pass --config <dir>)."
|
||||
}
|
||||
Command::Policy { .. } | Command::Queries { .. } => {
|
||||
" It operates on a cluster (pass --cluster <dir|uri>, or select a cluster profile)."
|
||||
}
|
||||
_ => " It operates on a cluster.",
|
||||
},
|
||||
Capability::Local => " It does not address a graph.",
|
||||
Capability::Any | Capability::Served => "",
|
||||
}
|
||||
|
|
@ -286,6 +325,7 @@ mod tests {
|
|||
// The one Data→Served refinement — if the `graphs` guard were deleted,
|
||||
// every other assertion here would still pass.
|
||||
assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served);
|
||||
assert_eq!(cap(&["omnigraph", "alias", "who"]), Capability::Local);
|
||||
assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control);
|
||||
|
|
|
|||
|
|
@ -186,9 +186,9 @@ fn scope_from_binding(
|
|||
ScopeBinding::Cluster(cluster) => {
|
||||
if capability == Capability::Any {
|
||||
bail!(
|
||||
"{source} resolves a cluster scope, which is maintenance-only; run \
|
||||
data commands through a server, or use --store <uri> for ad-hoc \
|
||||
direct access"
|
||||
"{source} resolves a cluster scope, which is not valid for graph data \
|
||||
commands; run data commands through a server, or use --store <uri> \
|
||||
for ad-hoc direct access"
|
||||
);
|
||||
}
|
||||
// A cluster value is a config name (resolved against `clusters:`)
|
||||
|
|
@ -501,7 +501,7 @@ mod tests {
|
|||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("maintenance-only"), "{err}");
|
||||
assert!(err.contains("not valid for graph data commands"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2127,7 +2127,26 @@ fn profile_list_json_shape() {
|
|||
.find(|p| p["name"] == "brain-admin")
|
||||
.unwrap();
|
||||
assert_eq!(brain["binding"], "cluster: brain");
|
||||
assert_eq!(brain["scope_kind"], "cluster");
|
||||
assert_eq!(brain["target"], "brain");
|
||||
assert_eq!(brain["valid"], true);
|
||||
assert!(brain["error"].is_null());
|
||||
assert_eq!(brain["active"], false);
|
||||
let broken = items
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "broken")
|
||||
.unwrap();
|
||||
assert_eq!(broken["scope_kind"], "invalid");
|
||||
assert_eq!(broken["valid"], false);
|
||||
assert!(broken["target"].is_null());
|
||||
assert!(
|
||||
broken["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("profile 'broken'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -94,6 +94,49 @@ fn alias_unknown_name_errors_listing_defined() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
|
||||
for (flag, value) in [
|
||||
("--server", "dev"),
|
||||
("--graph", "local"),
|
||||
("--store", "file:///tmp/graph.omni"),
|
||||
("--cluster", "."),
|
||||
("--profile", "prod"),
|
||||
("--as", "act-op"),
|
||||
] {
|
||||
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`alias` uses the server, graph, and stored query")
|
||||
&& stderr.contains(flag),
|
||||
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
|
||||
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"queries should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("--server")
|
||||
.arg("prod")
|
||||
.arg("policy")
|
||||
.arg("validate"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"policy should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// RFC-011: `queries validate`/`list` source the registry + schemas from a
|
||||
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
|
||||
|
||||
|
|
|
|||
|
|
@ -1398,154 +1398,6 @@ fn local_cli_ingest_enforces_engine_layer_policy() {
|
|||
assert_eq!(allowed["branch_created"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_cli_schema_apply_enforces_engine_layer_policy() {
|
||||
// RFC-011 served re-point: the server enforces schema_apply against the
|
||||
// graph-bound bundle. Bruno has no schema_apply rule → denied; ragnor
|
||||
// has admins-schema-apply → allowed. The schema is additive (a nullable
|
||||
// property), SDK-compatible with the fixture.
|
||||
if skip_system_e2e("local_cli_schema_apply_enforces_engine_layer_policy") {
|
||||
return;
|
||||
}
|
||||
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
|
||||
let server = spawn_server_with_cluster_env(
|
||||
cluster.path(),
|
||||
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
|
||||
);
|
||||
let new_schema = std::fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace(
|
||||
" age: I32?\n}",
|
||||
" age: I32?\n nickname: String?\n}",
|
||||
);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let schema_path = temp.path().join("policy-additive.pg");
|
||||
std::fs::write(&schema_path, &new_schema).unwrap();
|
||||
|
||||
let denied = cli()
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--server")
|
||||
.arg(&server.base_url)
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!denied.status.success(), "bruno schema apply must be denied");
|
||||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||||
assert!(
|
||||
stderr.contains("denied"),
|
||||
"expected 'denied' for bruno schema apply, got: {stderr}"
|
||||
);
|
||||
|
||||
let allowed = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--server")
|
||||
.arg(&server.base_url)
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(allowed["applied"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_cli_schema_apply_rejects_stored_query_breakage_before_publish() {
|
||||
// RFC-011: stored queries live in the cluster catalog, not omnigraph.yaml.
|
||||
// The served `schema apply` runs the server's catalog check against the
|
||||
// applied stored queries; renaming `age`→`years` breaks the bundled
|
||||
// `find_person` (which projects `$p.age`), so the apply is rejected before
|
||||
// publish — the schema stays unchanged.
|
||||
if skip_system_e2e("local_cli_schema_apply_rejects_stored_query_breakage_before_publish") {
|
||||
return;
|
||||
}
|
||||
// A graph-bound bundle that lets ragnor apply schema, plus a stored query
|
||||
// `find_person` projecting $p.age (the catalog the server checks against).
|
||||
let cluster = tempfile::tempdir().unwrap();
|
||||
let dir = cluster.path();
|
||||
fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
fs::write(
|
||||
dir.join("find-person.gq"),
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.join("graph.policy.yaml"), POLICY_E2E_YAML).unwrap();
|
||||
fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n knowledge:\n schema: ./graph.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [knowledge]\n",
|
||||
)
|
||||
.unwrap();
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg(dir.join("graphs").join("knowledge.omni")),
|
||||
);
|
||||
let server = spawn_server_with_cluster_env(
|
||||
dir,
|
||||
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
|
||||
);
|
||||
|
||||
let renamed_schema = std::fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("age: I32?", "years: I32? @rename_from(\"age\")");
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let schema_path = temp.path().join("stored-query-breaks.pg");
|
||||
fs::write(&schema_path, &renamed_schema).unwrap();
|
||||
|
||||
let rejected = cli()
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--server")
|
||||
.arg(&server.base_url)
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!rejected.status.success(),
|
||||
"schema apply that breaks a stored query must be rejected"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&rejected.stderr);
|
||||
assert!(
|
||||
stderr.contains("find_person") && stderr.contains("schema check"),
|
||||
"schema apply should reject the stored-query breakage before publish; stderr: {stderr}"
|
||||
);
|
||||
|
||||
// The schema stayed unchanged (read it back via the served graph as the
|
||||
// bruno reader, who holds `team-read`).
|
||||
let schema = stdout_string(&output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
|
||||
.arg("schema")
|
||||
.arg("show")
|
||||
.arg("--server")
|
||||
.arg(&server.base_url)
|
||||
.arg("--graph")
|
||||
.arg("knowledge"),
|
||||
));
|
||||
assert!(schema.contains("age: I32?"));
|
||||
assert!(!schema.contains("years: I32?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_cli_branch_create_enforces_engine_layer_policy() {
|
||||
// RFC-011 served re-point: bruno has no branch-ops rule → denied;
|
||||
|
|
@ -2482,14 +2334,19 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
|
|||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
cluster.path().join("insert-person.gq"),
|
||||
"query insert_person($name: String) { insert Person { name: $name, age: 41 } }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
cluster.path().join("graph.policy.yaml"),
|
||||
"version: 1\ngroups:\n ops: [\"act-op\"]\nprotected_branches: [main]\nrules:\n - id: allow-invoke\n allow:\n actors: { group: ops }\n actions: [invoke_query]\n - id: allow-read\n allow:\n actors: { group: ops }\n actions: [read]\n branch_scope: any\n",
|
||||
"version: 1\ngroups:\n ops: [\"act-op\"]\nprotected_branches: [main]\nrules:\n - id: allow-invoke\n allow:\n actors: { group: ops }\n actions: [invoke_query]\n - id: allow-read\n allow:\n actors: { group: ops }\n actions: [read]\n branch_scope: any\n - id: allow-change\n allow:\n actors: { group: ops }\n actions: [change]\n branch_scope: any\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
cluster.path().join("cluster.yaml"),
|
||||
"version: 1\nmetadata:\n name: alias-sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n local:\n schema: ./local.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [local]\n",
|
||||
"version: 1\nmetadata:\n name: alias-sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n local:\n schema: ./local.pg\n queries:\n find_person:\n file: ./find-person.gq\n insert_person:\n file: ./insert-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [local]\n",
|
||||
)
|
||||
.unwrap();
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(cluster.path()));
|
||||
|
|
@ -2515,7 +2372,7 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
|
|||
fs::write(
|
||||
operator_home.path().join("config.yaml"),
|
||||
format!(
|
||||
"servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n",
|
||||
"servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n create_person:\n server: dev\n graph: local\n query: insert_person\n args: [name]\n",
|
||||
server.base_url
|
||||
),
|
||||
)
|
||||
|
|
@ -2552,6 +2409,46 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
|
|||
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}");
|
||||
|
||||
// Operator aliases are read-only conveniences: a binding to a stored
|
||||
// mutation must be rejected before the server executes it.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("alias")
|
||||
.arg("create_person")
|
||||
.arg("AliasGuardPerson")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "mutation alias must fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("'insert_person' is a mutation")
|
||||
&& stderr.contains("omnigraph mutate insert_person"),
|
||||
"expected mutation-kind mismatch; got: {stderr}"
|
||||
);
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("query")
|
||||
.arg("find_person")
|
||||
.arg("--server")
|
||||
.arg("dev")
|
||||
.arg("--graph")
|
||||
.arg("local")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"AliasGuardPerson"}"#)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"post-alias read should succeed: {output:?}"
|
||||
);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
payload["rows"].as_array().unwrap().len(),
|
||||
0,
|
||||
"mutation alias must not insert AliasGuardPerson: {payload}"
|
||||
);
|
||||
|
||||
// --server/--graph: the same stored query via explicit targeting.
|
||||
let output = cli()
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
|
|
|
|||
|
|
@ -53,14 +53,13 @@ pub(crate) async fn server_graphs_list(
|
|||
) -> std::result::Result<Json<GraphListResponse>, ApiError> {
|
||||
let registry = &state.routing().registry;
|
||||
|
||||
// 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).
|
||||
// Server-level Cedar gate. `state.server_policy` is loaded from the
|
||||
// cluster-scoped policy bundle 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(),
|
||||
|
|
@ -360,22 +359,25 @@ pub(crate) fn authorize(
|
|||
// runtime state means the docstring contract on
|
||||
// `server_graphs_list` ("don't leak the registry until the
|
||||
// operator explicitly authorizes it") holds uniformly; the
|
||||
// operator's only path to enabling it is configuring an
|
||||
// explicit `server.policy.file` in omnigraph.yaml.
|
||||
// operator's only path to enabling it is configuring a
|
||||
// cluster-scoped policy bundle, applying the cluster, and
|
||||
// restarting the server.
|
||||
if request.action.resource_kind() == PolicyResourceKind::Server {
|
||||
return Ok(Authz::Denied(
|
||||
"server-scoped actions require an explicit `server.policy.file` \
|
||||
configured in omnigraph.yaml — the management surface is closed \
|
||||
by default in every runtime state, including --unauthenticated, \
|
||||
so that server topology is never exposed without operator opt-in."
|
||||
"server-scoped actions require an explicit cluster policy bundle \
|
||||
applied with `omnigraph cluster apply` and served after restart — \
|
||||
the management surface is closed by default in every runtime state, \
|
||||
including --unauthenticated, so that server topology is never exposed \
|
||||
without operator opt-in."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if actor.is_some() && request.action != PolicyAction::Read {
|
||||
return Ok(Authz::Denied(
|
||||
"server runs in default-deny mode (bearer tokens configured but no \
|
||||
policy file). Only `read` actions are permitted; configure \
|
||||
`policy.file` in omnigraph.yaml to enable other actions."
|
||||
applied policy bundle). Only `read` actions are permitted; configure \
|
||||
a graph or cluster policy bundle in the cluster config, run \
|
||||
`omnigraph cluster apply`, and restart the server to enable other actions."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -488,7 +490,7 @@ pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName,
|
|||
operation_id = "read",
|
||||
request_body = ReadRequest,
|
||||
responses(
|
||||
(status = 200, description = "Query results (response includes `Deprecation: true` + `Link: </query>; rel=\"successor-version\"`)", body = ReadOutput),
|
||||
(status = 200, description = "Query results (response includes `Deprecation: true` + `Link: <query>; rel=\"successor-version\"`)", body = ReadOutput),
|
||||
(status = 400, description = "Bad request", body = ErrorOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
|
|
@ -502,7 +504,7 @@ pub(crate) fn deprecation_headers(successor_link: &'static str) -> [(HeaderName,
|
|||
/// route is kept indefinitely for byte-stable back-compat. New integrations
|
||||
/// should target `POST /query`, which has clean field names (`query` /
|
||||
/// `name`) and a 400-on-mutation guard. Responses from this route include
|
||||
/// `Deprecation: true` and `Link: </query>; rel="successor-version"`
|
||||
/// `Deprecation: true` and `Link: <query>; rel="successor-version"`
|
||||
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the
|
||||
/// signal.
|
||||
pub(crate) async fn server_read(
|
||||
|
|
@ -522,7 +524,7 @@ pub(crate) async fn server_read(
|
|||
)
|
||||
.await?;
|
||||
Ok((
|
||||
deprecation_headers("</query>; rel=\"successor-version\""),
|
||||
deprecation_headers("<query>; rel=\"successor-version\""),
|
||||
Json(api::read_output(selected_name, &target, result)),
|
||||
))
|
||||
}
|
||||
|
|
@ -771,7 +773,7 @@ pub(crate) async fn run_query(
|
|||
operation_id = "change",
|
||||
request_body = ChangeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mutation results (response includes `Deprecation: true` + `Link: </mutate>; rel=\"successor-version\"`)", body = ChangeOutput),
|
||||
(status = 200, description = "Mutation results (response includes `Deprecation: true` + `Link: <mutate>; rel=\"successor-version\"`)", body = ChangeOutput),
|
||||
(status = 400, description = "Bad request", body = ErrorOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
|
|
@ -787,7 +789,7 @@ pub(crate) async fn run_query(
|
|||
/// kept indefinitely for back-compat. New integrations should target
|
||||
/// `POST /mutate`, which has identical semantics and a name that pairs
|
||||
/// cleanly with `POST /query`. Responses from this route include
|
||||
/// `Deprecation: true` and `Link: </mutate>; rel="successor-version"`
|
||||
/// `Deprecation: true` and `Link: <mutate>; rel="successor-version"`
|
||||
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the
|
||||
/// signal.
|
||||
pub(crate) async fn server_change(
|
||||
|
|
@ -808,7 +810,7 @@ pub(crate) async fn server_change(
|
|||
)
|
||||
.await?;
|
||||
Ok((
|
||||
deprecation_headers("</mutate>; rel=\"successor-version\""),
|
||||
deprecation_headers("<mutate>; rel=\"successor-version\""),
|
||||
Json(output),
|
||||
))
|
||||
}
|
||||
|
|
@ -1111,12 +1113,16 @@ pub(crate) async fn server_schema_get(
|
|||
(status = 400, description = "Bad request", body = ErrorOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
(status = 409, description = "Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart", body = ErrorOutput),
|
||||
(status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput),
|
||||
),
|
||||
security(("bearer_token" = [])),
|
||||
)]
|
||||
/// Apply a schema migration.
|
||||
///
|
||||
/// Cluster-backed servers reject this route with `409 Conflict`; operators
|
||||
/// must apply schema changes through `omnigraph cluster apply` and restart.
|
||||
///
|
||||
/// Diffs `schema_source` against the current schema and applies the resulting
|
||||
/// migration steps (add/drop type, add/drop column, etc.). **Destructive**:
|
||||
/// some steps drop data. Returns the list of steps applied; if `applied` is
|
||||
|
|
@ -1143,6 +1149,17 @@ pub(crate) async fn server_schema_apply(
|
|||
target_branch: Some("main".to_string()),
|
||||
},
|
||||
)?;
|
||||
// Disable HTTP schema apply on cluster-backed serving AFTER the Cedar gate,
|
||||
// so an unauthorized actor gets a 403 (not a 409 that would disclose the
|
||||
// server is cluster-backed): 401 → 403 → 409, never leak topology before
|
||||
// authorization. An authorized actor gets the actionable 409 signpost.
|
||||
if state.routing().config_path.is_some() {
|
||||
return Err(ApiError::conflict(
|
||||
"server-side schema apply is disabled for cluster-backed serving; \
|
||||
update the cluster config, run `omnigraph cluster apply`, and restart \
|
||||
the server.",
|
||||
));
|
||||
}
|
||||
let est_bytes = request.schema_source.len() as u64;
|
||||
let _admission = state
|
||||
.workload
|
||||
|
|
@ -1324,7 +1341,7 @@ pub(crate) async fn server_load(
|
|||
operation_id = "ingest",
|
||||
request_body = IngestRequest,
|
||||
responses(
|
||||
(status = 200, description = "Load results (response includes `Deprecation: true` + `Link: </load>; rel=\"successor-version\"`)", body = IngestOutput),
|
||||
(status = 200, description = "Load results (response includes `Deprecation: true` + `Link: <load>; rel=\"successor-version\"`)", body = IngestOutput),
|
||||
(status = 400, description = "Bad request", body = ErrorOutput),
|
||||
(status = 401, description = "Unauthorized", body = ErrorOutput),
|
||||
(status = 403, description = "Forbidden", body = ErrorOutput),
|
||||
|
|
@ -1338,7 +1355,7 @@ pub(crate) async fn server_load(
|
|||
/// Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is
|
||||
/// kept indefinitely for back-compat. New integrations should target
|
||||
/// `POST /load`, which has identical semantics. Responses from this route
|
||||
/// include `Deprecation: true` and `Link: </load>; rel="successor-version"`
|
||||
/// include `Deprecation: true` and `Link: <load>; rel="successor-version"`
|
||||
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.
|
||||
pub(crate) async fn server_ingest(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -1354,7 +1371,7 @@ pub(crate) async fn server_ingest(
|
|||
)
|
||||
.await?;
|
||||
Ok((
|
||||
deprecation_headers("</load>; rel=\"successor-version\""),
|
||||
deprecation_headers("<load>; rel=\"successor-version\""),
|
||||
Json(output),
|
||||
))
|
||||
}
|
||||
|
|
@ -1738,4 +1755,3 @@ pub(crate) fn query_params_from_json(
|
|||
json_params_to_param_map(params_json, query_params, JsonParamMode::Standard)
|
||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,10 +191,10 @@ pub enum ServerConfigMode {
|
|||
},
|
||||
}
|
||||
|
||||
/// Where a Cedar policy bundle comes from at startup. File-based for
|
||||
/// omnigraph.yaml deployments; inline (digest-verified catalog content)
|
||||
/// for cluster-mode boots, where the catalog may live on object storage
|
||||
/// and the server must not re-read mutable state after the snapshot.
|
||||
/// Where a Cedar policy bundle comes from at startup. Cluster-local files are
|
||||
/// used during config application; inline digest-verified catalog content is
|
||||
/// used for serving, where the catalog may live on object storage and the
|
||||
/// server must not re-read mutable state after the snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PolicySource {
|
||||
File(PathBuf),
|
||||
|
|
@ -249,12 +249,10 @@ pub struct AppState {
|
|||
/// see MR-668 decision Q6.
|
||||
workload: Arc<workload::WorkloadController>,
|
||||
bearer_tokens: Arc<[(BearerTokenHash, Arc<str>)]>,
|
||||
/// Server-level Cedar policy. Used by management endpoints (`POST
|
||||
/// /graphs`, `GET /graphs`) which act on the registry resource,
|
||||
/// not on a per-graph resource. Loaded from `server.policy.file`
|
||||
/// in `omnigraph.yaml`. `None` outside multi mode and when no
|
||||
/// server policy is configured. Per-graph policies live on each
|
||||
/// `GraphHandle.policy`.
|
||||
/// Server-level Cedar policy. Used by management endpoints (`GET
|
||||
/// /graphs`) which act on the registry resource, not on a per-graph
|
||||
/// resource. Loaded from the cluster-scoped policy binding when
|
||||
/// configured. Per-graph policies live on each `GraphHandle.policy`.
|
||||
server_policy: Option<Arc<PolicyEngine>>,
|
||||
}
|
||||
|
||||
|
|
@ -534,12 +532,11 @@ impl AppState {
|
|||
}
|
||||
|
||||
/// Multi-mode constructor — used by the startup loop. Operators
|
||||
/// reach this by invoking `omnigraph-server --config omnigraph.yaml`
|
||||
/// with a non-empty `graphs:` map.
|
||||
/// reach this by invoking `omnigraph-server --cluster <dir|s3://...>`.
|
||||
///
|
||||
/// Caller supplies the already-opened `GraphHandle`s and (optionally)
|
||||
/// the path to the source config file. `server_policy` is loaded
|
||||
/// from `server.policy.file` if configured.
|
||||
/// the path to the source cluster. `server_policy` is loaded from the
|
||||
/// cluster-scoped policy binding if configured.
|
||||
pub fn new_multi(
|
||||
handles: Vec<Arc<GraphHandle>>,
|
||||
bearer_tokens: Vec<(String, String)>,
|
||||
|
|
@ -993,7 +990,8 @@ pub async fn serve(config: ServerConfig) -> Result<()> {
|
|||
ServerRuntimeState::DefaultDeny => warn!(
|
||||
"bearer tokens are configured but no policy file is set — running in \
|
||||
default-deny mode (only `read` actions are permitted for authenticated \
|
||||
actors). Configure `policy.file` in omnigraph.yaml to enable Cedar rules."
|
||||
actors). Configure a graph or cluster policy bundle in the cluster config, \
|
||||
run `omnigraph cluster apply`, and restart to enable Cedar rules."
|
||||
),
|
||||
ServerRuntimeState::PolicyEnabled => {}
|
||||
}
|
||||
|
|
@ -1123,5 +1121,3 @@ async fn shutdown_signal() {
|
|||
}
|
||||
info!("shutdown signal received");
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
//! Server settings: omnigraph.yaml/CLI/env resolution, mode inference
|
||||
//! (single vs multi vs cluster), bearer-token sources, and runtime-state
|
||||
//! classification (moved verbatim from lib.rs in the modularization).
|
||||
//! Server settings: cluster/CLI/env resolution, bearer-token sources, and
|
||||
//! runtime-state classification (moved verbatim from lib.rs in the
|
||||
//! modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build serving settings from a cluster directory's applied revision
|
||||
/// (RFC-005 §D2): graphs at derived roots, stored queries from verified
|
||||
/// catalog blob content, policy bundles from blob paths with their applied
|
||||
/// bindings. Always multi-graph routing. The unauthenticated/env handling
|
||||
/// matches the omnigraph.yaml path.
|
||||
/// bindings. Always multi-graph routing.
|
||||
pub(crate) async fn load_cluster_settings(
|
||||
cluster_dir: &PathBuf,
|
||||
cli_bind: Option<String>,
|
||||
|
|
@ -189,7 +188,8 @@ pub fn classify_server_runtime_state(
|
|||
"server has no bearer tokens and no policy file configured. This is a fully \
|
||||
open server — pass `--unauthenticated` (or set OMNIGRAPH_UNAUTHENTICATED=1) \
|
||||
if you actually want that, otherwise configure bearer tokens (see \
|
||||
docs/user/operations/server.md) and/or `policy.file` in omnigraph.yaml."
|
||||
docs/user/operations/server.md) and a graph or cluster policy bundle in \
|
||||
the cluster config, then run `omnigraph cluster apply` and restart."
|
||||
),
|
||||
(false, false, true) => Ok(ServerRuntimeState::Open),
|
||||
(true, false, _) => Ok(ServerRuntimeState::DefaultDeny),
|
||||
|
|
|
|||
|
|
@ -580,7 +580,7 @@ async fn mutate_endpoint_runs_inline_mutation() {
|
|||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn change_endpoint_emits_deprecation_headers() {
|
||||
// `/change` is kept indefinitely for back-compat but flagged at runtime
|
||||
// per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: </mutate>;
|
||||
// per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: <mutate>;
|
||||
// rel="successor-version"`). The OpenAPI side is covered by
|
||||
// `openapi_change_is_deprecated` in tests/openapi.rs.
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
|
|
@ -615,7 +615,7 @@ async fn change_endpoint_emits_deprecation_headers() {
|
|||
);
|
||||
assert_eq!(
|
||||
response.headers().get("link").and_then(|v| v.to_str().ok()),
|
||||
Some("</mutate>; rel=\"successor-version\""),
|
||||
Some("<mutate>; rel=\"successor-version\""),
|
||||
"POST /change must point at /mutate via `Link` rel=successor-version (RFC 8288)"
|
||||
);
|
||||
}
|
||||
|
|
@ -658,7 +658,7 @@ async fn load_endpoint_loads_into_existing_branch() {
|
|||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ingest_endpoint_emits_deprecation_headers() {
|
||||
// `/ingest` is the deprecated alias of `/load` (RFC-009 Phase 5): flagged
|
||||
// at runtime per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: </load>;
|
||||
// at runtime per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: <load>;
|
||||
// rel="successor-version"`). The OpenAPI side is covered by
|
||||
// `openapi_ingest_is_deprecated` in tests/openapi.rs.
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
|
|
@ -692,7 +692,7 @@ async fn ingest_endpoint_emits_deprecation_headers() {
|
|||
);
|
||||
assert_eq!(
|
||||
response.headers().get("link").and_then(|v| v.to_str().ok()),
|
||||
Some("</load>; rel=\"successor-version\""),
|
||||
Some("<load>; rel=\"successor-version\""),
|
||||
"POST /ingest must point at /load via `Link` rel=successor-version (RFC 8288)"
|
||||
);
|
||||
}
|
||||
|
|
@ -734,7 +734,7 @@ async fn read_endpoint_emits_deprecation_headers() {
|
|||
);
|
||||
assert_eq!(
|
||||
response.headers().get("link").and_then(|v| v.to_str().ok()),
|
||||
Some("</query>; rel=\"successor-version\""),
|
||||
Some("<query>; rel=\"successor-version\""),
|
||||
"POST /read must point at /query via `Link` rel=successor-version (RFC 8288)"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
|
|
@ -11,7 +12,9 @@ use omnigraph::loader::LoadMode;
|
|||
use omnigraph_server::api::{
|
||||
ChangeRequest, ErrorOutput, ReadRequest, SchemaApplyRequest, SchemaOutput,
|
||||
};
|
||||
use omnigraph_server::{AppState, build_app};
|
||||
use omnigraph_server::{
|
||||
AppState, GraphHandle, GraphId, GraphKey, PolicyEngine, build_app, workload,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
|
|
@ -54,6 +57,111 @@ async fn schema_apply_route_updates_graph_for_authorized_admin() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_refuses_cluster_backed_server_mode() {
|
||||
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
||||
let graph = graph_path(temp.path());
|
||||
let graph_uri = graph.to_string_lossy().to_string();
|
||||
let engine = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("default").unwrap()),
|
||||
uri: graph_uri.clone(),
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let state = AppState::new_multi(
|
||||
vec![handle],
|
||||
Vec::new(),
|
||||
None,
|
||||
workload::WorkloadController::from_env(),
|
||||
Some(temp.path().join("cluster.yaml")),
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT, "body: {payload}");
|
||||
assert!(
|
||||
payload["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("cluster apply"),
|
||||
"body: {payload}"
|
||||
);
|
||||
let reopened = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
assert!(
|
||||
!reopened.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("nickname"),
|
||||
"cluster-backed schema apply must not mutate the graph"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_cluster_backed_denies_unauthorized_actor_before_409() {
|
||||
// The cluster-backed 409 is reported AFTER the Cedar gate, so an actor
|
||||
// without `schema_apply` permission gets a 403 — never a 409 that would
|
||||
// disclose the server is cluster-backed (401 → 403 → 409, no topology leak
|
||||
// before authorization). POLICY_YAML grants read/export but not schema_apply,
|
||||
// so act-ragnor is denied.
|
||||
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
||||
let graph = graph_path(temp.path());
|
||||
let graph_uri = graph.to_string_lossy().to_string();
|
||||
let engine = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
let policy = PolicyEngine::load_graph_from_source(POLICY_YAML, "default").unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("default").unwrap()),
|
||||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: Some(Arc::new(policy)),
|
||||
queries: None,
|
||||
});
|
||||
let state = AppState::new_multi(
|
||||
vec![handle],
|
||||
vec![("act-ragnor".to_string(), "admin-token".to_string())],
|
||||
None,
|
||||
workload::WorkloadController::from_env(),
|
||||
Some(temp.path().join("cluster.yaml")),
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"an unauthorized actor must get 403 before the cluster-backed 409: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_rejects_stored_query_breakage_before_publish() {
|
||||
let (temp, app) = app_with_stored_queries(
|
||||
|
|
|
|||
|
|
@ -59,10 +59,11 @@ omnigraph commit show --uri graph.omni <commit-id> --json
|
|||
|
||||
## Remote Server Mode
|
||||
|
||||
Serve a graph:
|
||||
Serve a cluster-applied graph:
|
||||
|
||||
```bash
|
||||
omnigraph-server graph.omni --bind 127.0.0.1:8080
|
||||
omnigraph cluster apply --config ./company-brain
|
||||
omnigraph-server --cluster ./company-brain --bind 127.0.0.1:8080
|
||||
```
|
||||
|
||||
Read through the HTTP API — invoke a stored query by name from the catalog:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A reference for the `omnigraph` binary's command surface and the per-operator `~/.omnigraph/config.yaml` schema. For a quick-start guide, see [cli.md](index.md).
|
||||
|
||||
Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server <name|url>` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph <id>` for multi-graph servers; exclusive with a positional URI), `--store <uri>` (a single graph's storage directly), or `--profile <name>` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config <dir>`. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected. **`query`/`mutate` are the exception**: their positional is a stored-query *name* (RFC-011 D3), not a graph URI, so they address the graph only via `--store`/`--server`/`--profile`/defaults.
|
||||
Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server <name|url>` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph <id>` for multi-graph servers; exclusive with a positional URI), `--store <uri>` (a single graph's storage directly), or `--profile <name>` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config <dir>`, while `policy` and `queries` read a cluster's applied state via `--cluster <dir|uri>`. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected. **`query`/`mutate` are the exception**: their positional is a stored-query *name* (RFC-011 D3), not a graph URI, so they address the graph only via `--store`/`--server`/`--profile`/defaults.
|
||||
|
||||
## Top-level commands
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po
|
|||
| `ingest` | deprecated alias of `load --from <base>` (defaults: `--from main --mode merge`); prints a one-line warning to stderr |
|
||||
| `query <name>` (alias: `read`) | run a read query. **Catalog lane** (default): `<name>` is a stored query invoked **by name** from the served catalog (served-only — address with `--server`/`--profile`; the verb asserts the query is a read). **Ad-hoc lane**: with `--query <path>` or `-e`/`--query-string <GQ>`, runs that source (the positional `<name>` then selects which query in it). No positional graph URI — address via `--store`/`--server`/`--profile`. `read` is the deprecated previous name (one-line stderr warning) |
|
||||
| `mutate <name>` (alias: `change`) | run a mutation query; same catalog (by-name, served-only, verb asserts mutation) / ad-hoc (`--query`/`-e`) lanes as `query`. `change` is the deprecated previous name (one-line stderr warning) |
|
||||
| `alias <name> [args]` | invoke an operator alias — a personal binding (under `aliases:` in `~/.omnigraph/config.yaml`) to a stored query on a named server (RFC-011 D4; replaces the removed `--alias` flag) |
|
||||
| `alias <name> [args]` | invoke an operator alias — a read-only personal binding (under `aliases:` in `~/.omnigraph/config.yaml`) to a stored query on a named server (RFC-011 D4; replaces the removed `--alias` flag; stored mutations are rejected before execution) |
|
||||
| `snapshot` | print current snapshot (per-table version + row count) |
|
||||
| `export` | dump to JSONL on stdout (`--type T`, `--table K` filters) |
|
||||
| `branch create \| list \| delete \| merge` | branching ops |
|
||||
|
|
@ -26,7 +26,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po
|
|||
| `cleanup --keep N --older-than 7d --confirm` | destructive version GC (`--confirm` to execute; also needs `--yes` against a non-local `s3://` target — see *Write diagnostics & destructive confirmation*) |
|
||||
| `embed` | offline JSONL embedding pipeline |
|
||||
| `policy validate \| test \| explain` | Cedar tooling against a cluster's applied policies (`--cluster <dir>`; `--graph <id>` picks a graph's bundle when several apply). `test` takes `--tests <file>`; `explain` takes `--actor`/`--action`/`--branch`/`--target-branch` |
|
||||
| `profile list \| show [<name>]` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults |
|
||||
| `profile list \| show [<name>]` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; JSON keeps `binding` and adds `scope_kind`, `target`, `valid`, and `error`; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults |
|
||||
| `version` / `-v` | print `omnigraph 0.3.x` |
|
||||
|
||||
## Command capabilities
|
||||
|
|
@ -35,13 +35,13 @@ Every command declares the **capability** it needs — what it requires to reach
|
|||
|
||||
- **`any`** — `query`, `mutate`, `load`, `ingest`, `branch *`, `snapshot`, `export`, `commit *`, `schema show`, `schema apply`. Run against a graph **served (via a server) or embedded (direct against a store)**: accept a positional `file://`/`s3://` URI, `--server <name|url>` (+ `--graph <id>` for multi-graph servers), `--store <uri>`, or `--profile <name>`. A remote server is addressed with `--server` — a positional `http(s)://` URI does **not** dispatch to one.
|
||||
- **`served`** — `graphs list`. Requires a server (accepts `--server` / `--profile`).
|
||||
- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `queries validate`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster <dir|s3://…> --graph <id>`** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `<storage>/graphs/<id>.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply.
|
||||
- **`control`** — `cluster *`. Operates on a cluster directory via `--config <dir>`.
|
||||
- **`local`** — `policy *`, `embed`, `login`, `logout`, `config`, `version`, `queries list`. Address no graph.
|
||||
- **`direct`** — `init`, `optimize`, `repair`, `cleanup`, `schema plan`, `lint`. Need **direct storage access** (`file://` / `s3://`), never through a server. They accept a positional `URI`, but **not** `--server`, and a remote (`http(s)://`) URI is rejected. `optimize` / `repair` / `cleanup` additionally accept **`--cluster <dir|s3://…> --graph <id>`** (`--cluster` is a cluster directory or storage-root URI, named via `clusters:` in `~/.omnigraph/config.yaml` or a literal root), which resolves the graph's storage URI from the served cluster state (so you needn't know the `<storage>/graphs/<id>.omni` layout). `--graph` is the one graph selector across all scopes — on these three verbs it picks the cluster graph; on the other `direct` verbs it does not apply.
|
||||
- **`control`** — `cluster *` via `--config <dir>`; `policy *` and `queries *` via `--cluster <dir|uri>` or a cluster profile.
|
||||
- **`local`** — `alias`, `embed`, `login`, `logout`, `profile`, `version`. Address no explicit graph scope.
|
||||
|
||||
These restrictions are enforced and reported, not silent:
|
||||
|
||||
- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside the maintenance verbs, or `--graph` where no multi-graph scope applies, e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster <dir> --graph <id>.``
|
||||
- A scope flag on a verb that can't consume it fails loudly rather than being silently dropped — `--server` outside a served scope, `--cluster` outside cluster-scoped verbs, or `--graph` where no multi-graph scope applies, e.g.: ``optimize is a direct (storage-native) command; --server addresses a served graph and does not apply. Pass a storage URI, or --cluster <dir> --graph <id>.``
|
||||
- A `direct` verb pointed at a remote URI fails loudly, e.g.: ``optimize is a direct (storage-native) command and needs direct storage access; the resolved target is a remote server (https://…). Pass the graph's file:// or s3:// URI.``
|
||||
- A data verb pointed at a positional `http(s)://` URI fails loudly: ``a remote graph must be addressed with --server <url> — a positional (or --uri) http(s):// URL no longer dispatches to a server.``
|
||||
- `init` into an **established cluster's** storage layout (`<root>/graphs/<id>.omni` where `<root>` holds `__cluster/state.json`) is refused — graphs in a cluster are created by `cluster apply` (which records ledger / recovery / approvals), not `init`.
|
||||
|
|
|
|||
|
|
@ -40,16 +40,16 @@ graph id from the cluster's applied revision:
|
|||
| GET | `/openapi.json` | none | — (strips security if auth disabled; emits the nested cluster paths with `cluster_` operation-id prefix) |
|
||||
| GET | `/graphs/{id}/snapshot?branch=` | bearer + `read` | snapshot of branch |
|
||||
| POST | `/graphs/{id}/query` | bearer + `read` | inline read query (canonical; clean field names `query`/`name`; mutations → 400) |
|
||||
| POST | `/graphs/{id}/read` | bearer + `read` | **deprecated** alias of `/query` (legacy field names `query_source`/`query_name`, byte-stable response; carries `Deprecation: true` + `Link: </query>; rel="successor-version"`) |
|
||||
| POST | `/graphs/{id}/read` | bearer + `read` | **deprecated** alias of `/query` (legacy field names `query_source`/`query_name`, byte-stable response; carries `Deprecation: true` + `Link: <query>; rel="successor-version"`) |
|
||||
| POST | `/graphs/{id}/export` | bearer + `export` | NDJSON stream |
|
||||
| POST | `/graphs/{id}/mutate` | bearer + `change` | mutation (canonical; `query`/`name`; accepts legacy `query_source`/`query_name` as serde aliases) |
|
||||
| POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: </mutate>; rel="successor-version"`) |
|
||||
| POST | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: <mutate>; rel="successor-version"`) |
|
||||
| GET | `/graphs/{id}/queries` | bearer + `read` | list the `mcp.expose` stored queries as a typed tool catalog |
|
||||
| POST | `/graphs/{id}/queries/{name}` | bearer + `invoke_query` (+ `change` for a stored mutation) | invoke a named query from the `queries:` registry; deny == 404 |
|
||||
| GET | `/graphs/{id}/schema` | bearer + `read` | get current `.pg` source |
|
||||
| POST | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | migrate |
|
||||
| POST | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | disabled for cluster-backed serving; returns 409 and points operators at `omnigraph cluster apply` + restart |
|
||||
| POST | `/graphs/{id}/load` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | bulk load (canonical); branch creation is opt-in via `from` — without it a missing `branch` is a 404, never an implicit fork (32 MB body limit) |
|
||||
| POST | `/graphs/{id}/ingest` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | **deprecated** alias of `/load` (carries `Deprecation: true` + `Link: </load>; rel="successor-version"`) (32 MB body limit) |
|
||||
| POST | `/graphs/{id}/ingest` | bearer + `branch_create` (only when `from` is set and the branch is created) + `change` | **deprecated** alias of `/load` (carries `Deprecation: true` + `Link: <load>; rel="successor-version"`) (32 MB body limit) |
|
||||
| GET | `/graphs/{id}/branches` | bearer + `read` | list branches |
|
||||
| POST | `/graphs/{id}/branches` | bearer + `branch_create` | create |
|
||||
| DELETE | `/graphs/{id}/branches/{branch}` | bearer + `branch_delete` | delete |
|
||||
|
|
@ -131,8 +131,8 @@ channels:
|
|||
- **Response headers (RFC 9745)**: every response carries `Deprecation: true`.
|
||||
- **Response headers (RFC 8288)**: every response carries a `Link` header
|
||||
pointing at the canonical successor:
|
||||
`Link: </query>; rel="successor-version"` for `/read`, and
|
||||
`Link: </mutate>; rel="successor-version"` for `/change`. SDKs and HTTP
|
||||
`Link: <query>; rel="successor-version"` for `/read`, and
|
||||
`Link: <mutate>; rel="successor-version"` for `/change`. SDKs and HTTP
|
||||
proxies can pick the successor up automatically.
|
||||
|
||||
Migration is purely cosmetic on the client side — swap the URL path, leave
|
||||
|
|
|
|||
24
openapi.json
24
openapi.json
|
|
@ -412,7 +412,7 @@
|
|||
"mutations"
|
||||
],
|
||||
"summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.",
|
||||
"description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: </mutate>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.",
|
||||
"description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: <mutate>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.",
|
||||
"operationId": "cluster_change",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -437,7 +437,7 @@
|
|||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Mutation results (response includes `Deprecation: true` + `Link: </mutate>; rel=\"successor-version\"`)",
|
||||
"description": "Mutation results (response includes `Deprecation: true` + `Link: <mutate>; rel=\"successor-version\"`)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
|
@ -731,7 +731,7 @@
|
|||
"mutations"
|
||||
],
|
||||
"summary": "**Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead.",
|
||||
"description": "Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /load`, which has identical semantics. Responses from this route\ninclude `Deprecation: true` and `Link: </load>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.",
|
||||
"description": "Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /load`, which has identical semantics. Responses from this route\ninclude `Deprecation: true` and `Link: <load>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.",
|
||||
"operationId": "cluster_ingest",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -756,7 +756,7 @@
|
|||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Load results (response includes `Deprecation: true` + `Link: </load>; rel=\"successor-version\"`)",
|
||||
"description": "Load results (response includes `Deprecation: true` + `Link: <load>; rel=\"successor-version\"`)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
|
@ -1275,7 +1275,7 @@
|
|||
"queries"
|
||||
],
|
||||
"summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.",
|
||||
"description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: </query>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.",
|
||||
"description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: <query>; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.",
|
||||
"operationId": "cluster_read",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -1300,7 +1300,7 @@
|
|||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Query results (response includes `Deprecation: true` + `Link: </query>; rel=\"successor-version\"`)",
|
||||
"description": "Query results (response includes `Deprecation: true` + `Link: <query>; rel=\"successor-version\"`)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
|
@ -1412,7 +1412,7 @@
|
|||
"mutations"
|
||||
],
|
||||
"summary": "Apply a schema migration.",
|
||||
"description": "Diffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.",
|
||||
"description": "Cluster-backed servers reject this route with `409 Conflict`; operators\nmust apply schema changes through `omnigraph cluster apply` and restart.\n\nDiffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.",
|
||||
"operationId": "cluster_applySchema",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -1476,6 +1476,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Per-actor admission cap exceeded; honor `Retry-After` header",
|
||||
"content": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue