omnigraph/crates/omnigraph-cli/src/planes.rs
Andrew Altshuler b5658dc696
[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>
2026-06-16 03:11:43 +03:00

357 lines
14 KiB
Rust

//! Declared CLI "planes" (RFC-010 Slice 1).
//!
//! Every subcommand belongs to exactly one plane. This classification is the
//! single source of truth the wrong-plane guard consumes — and that later
//! RFC-010 slices (the capability surface, plane-grouped help) will consume
//! too. The `command_plane` match is **exhaustive on purpose**: adding a
//! `Command` variant is a compile error until its plane is declared, so the
//! surface cannot silently drift from the command set.
//!
//! See [docs/dev/rfc-010-cli-planes-restructure.md].
use color_eyre::Result;
use color_eyre::eyre::bail;
use crate::cli::{Cli, Command, QueriesCommand, SchemaCommand};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Plane {
/// Runs against a graph, embedded **or** via `--server` (the `GraphClient`
/// axis). The only plane on which the data-plane addressing flags
/// (`--server`/`--graph`) apply.
Data,
/// Direct storage access; no server. Maintenance + local-only inspection
/// that must work with the server down.
Storage,
/// Operates on a cluster directory, not a graph URI.
Control,
/// Touches no graph at all — session / config / local tooling.
Session,
}
impl std::fmt::Display for Plane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Plane::Data => "data",
Plane::Storage => "storage",
Plane::Control => "control",
Plane::Session => "session",
})
}
}
/// What a command *needs*, in the user-facing vocabulary (RFC-011). This is the
/// language CLI errors and `--help` speak; `Plane` stays the internal classifier
/// (`Capability` is derived from it, so the two cannot drift).
///
/// - `any` — graph-scoped data; served via a server scope, or direct against a
/// store scope. Accepts `--server`/`--graph`.
/// - `served` — requires a server. Accepts `--server`/`--graph`.
/// - `direct` — storage-native; opens storage directly, never through a server.
/// - `control` — operates on a cluster (control plane).
/// - `local` — addresses no graph at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Capability {
Any,
Served,
Direct,
Control,
Local,
}
impl Capability {
/// A human phrase for error messages (`` `optimize` is a {…} command ``).
pub(crate) fn describe(self) -> &'static str {
match self {
Capability::Any => "data",
Capability::Served => "served",
Capability::Direct => "direct (storage-native)",
Capability::Control => "cluster control",
Capability::Local => "local",
}
}
/// `--server`/`--graph` are served-graph addressing: they apply only to the
/// capabilities that reach a graph through a server.
fn accepts_server_addressing(self) -> bool {
matches!(self, Capability::Any | Capability::Served)
}
}
/// The capability a subcommand needs, derived from its `Plane` (the exhaustive
/// classifier) plus the one Data→Served refinement: `graphs` is remote-only.
///
/// This reflects *current enforced behavior*, so messages stay truthful:
/// `queries`/`policy` read a cluster's applied state (`Control`).
pub(crate) fn command_capability(cmd: &Command) -> Capability {
if let Command::Graphs { .. } = cmd {
return Capability::Served;
}
match command_plane(cmd) {
Plane::Data => Capability::Any,
Plane::Storage => Capability::Direct,
Plane::Control => Capability::Control,
Plane::Session => Capability::Local,
}
}
/// 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`/`policy` read cluster applied state).
pub(crate) fn command_plane(cmd: &Command) -> Plane {
match cmd {
Command::Query { .. }
| Command::Mutate { .. }
| Command::Load { .. }
| Command::Ingest { .. }
| Command::Branch { .. }
| Command::Snapshot { .. }
| Command::Export { .. }
| Command::Commit { .. }
| Command::Graphs { .. } => Plane::Data,
Command::Schema {
command: SchemaCommand::Show { .. } | SchemaCommand::Apply { .. },
} => Plane::Data,
Command::Schema {
command: SchemaCommand::Plan { .. },
} => Plane::Storage,
// `queries` and `policy` tooling now source their inputs from a
// cluster's applied state (`--cluster`), so they live on the control
// plane (RFC-011 — omnigraph.yaml excised from the CLI).
Command::Queries { .. } => Plane::Control,
Command::Policy { .. } => Plane::Control,
Command::Init { .. }
| Command::Optimize { .. }
| Command::Repair { .. }
| Command::Cleanup { .. }
| Command::Lint { .. } => Plane::Storage,
Command::Cluster { .. } => Plane::Control,
Command::Alias { .. }
| Command::Embed(_)
| Command::Login { .. }
| Command::Logout { .. }
| Command::Profile { .. }
| Command::Version => Plane::Session,
}
}
/// User-facing label for a subcommand (descends one level for the nested
/// families so messages read `schema plan`, `queries validate`, etc.).
pub(crate) fn command_label(cmd: &Command) -> &'static str {
match cmd {
Command::Version => "version",
Command::Login { .. } => "login",
Command::Logout { .. } => "logout",
Command::Profile { .. } => "profile",
Command::Embed(_) => "embed",
Command::Init { .. } => "init",
Command::Load { .. } => "load",
Command::Ingest { .. } => "ingest",
Command::Branch { .. } => "branch",
Command::Schema { command } => match command {
SchemaCommand::Plan { .. } => "schema plan",
SchemaCommand::Apply { .. } => "schema apply",
SchemaCommand::Show { .. } => "schema show",
},
Command::Lint { .. } => "lint",
Command::Queries { command } => match command {
QueriesCommand::Validate { .. } => "queries validate",
QueriesCommand::List { .. } => "queries list",
},
Command::Snapshot { .. } => "snapshot",
Command::Export { .. } => "export",
Command::Commit { .. } => "commit",
Command::Query { .. } => "query",
Command::Mutate { .. } => "mutate",
Command::Alias { .. } => "alias",
Command::Policy { .. } => "policy",
Command::Optimize { .. } => "optimize",
Command::Repair { .. } => "repair",
Command::Cleanup { .. } => "cleanup",
Command::Cluster { .. } => "cluster",
Command::Graphs { .. } => "graphs",
}
}
/// 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,
Command::Optimize { .. }
| Command::Repair { .. }
| Command::Cleanup { .. }
// `lint` can type-check a `.gq` against a cluster graph's schema
// (RFC-011): `--cluster <dir> --graph <id>`.
| Command::Lint { .. }
// The policy/queries tooling addresses a cluster's applied state
// (RFC-011): `--cluster <dir>` selects the cluster, `--graph <id>`
// picks a graph's bundle/registry within it.
| Command::Policy { .. }
| Command::Queries { .. }
)
}
/// 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). `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` → 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(());
}
let capability = command_capability(&cli.command);
let label = command_label(&cli.command);
let cluster_ok = accepts_cluster_addressing(&cli.command);
if cli.server.is_some() && !capability.accepts_server_addressing() {
bail!(
"`{label}` is a {} command; --server addresses a served graph and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
if cli.cluster.is_some() && !cluster_ok {
bail!(
"`{label}` is a {} command; --cluster addresses a cluster-scoped command \
and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) {
bail!(
"`{label}` is a {} command; --graph selects a graph within a server or cluster \
scope and does not apply.{}",
capability.describe(),
remediation(capability, &cli.command),
);
}
Ok(())
}
/// The "what to do instead" tail for a wrong-address error, by capability.
/// Includes its own leading space when non-empty so the caller appends it
/// directly — an empty tail (the served-addressing capabilities, which only
/// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space.
fn remediation(capability: Capability, cmd: &Command) -> &'static str {
match capability {
Capability::Direct => match cmd {
Command::Init { .. } => " Pass a storage URI.",
Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } => {
" Pass a storage URI, or --cluster <dir> --graph <id>."
}
_ => " Pass a storage URI.",
},
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 => "",
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn server_addressing_allowed_exactly_on_any_and_served() {
// The behavior-preservation contract: `--server`/`--graph` apply to the
// served-graph capabilities (`any`, `served`) and nothing else. This is
// the old "Data plane only" allow set, re-expressed — graphs (the one
// Data→Served verb) was already allowed.
assert!(Capability::Any.accepts_server_addressing());
assert!(Capability::Served.accepts_server_addressing());
assert!(!Capability::Direct.accepts_server_addressing());
assert!(!Capability::Control.accepts_server_addressing());
assert!(!Capability::Local.accepts_server_addressing());
}
#[test]
fn command_capability_classifies_representative_verbs() {
let cap = |args: &[&str]| {
command_capability(&Cli::try_parse_from(args).unwrap().command)
};
// 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);
assert_eq!(cap(&["omnigraph", "version"]), Capability::Local);
// `queries`/`policy` tooling reads cluster state now (control plane).
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Control);
assert_eq!(
cap(&["omnigraph", "policy", "validate"]),
Capability::Control
);
}
#[test]
fn every_capability_describes_distinctly() {
let phrases = [
Capability::Any.describe(),
Capability::Served.describe(),
Capability::Direct.describe(),
Capability::Control.describe(),
Capability::Local.describe(),
];
for (i, a) in phrases.iter().enumerate() {
assert!(!a.is_empty());
for b in &phrases[i + 1..] {
assert_ne!(a, b);
}
}
}
}