diff --git a/crates/omnigraph-policy/src/lib.rs b/crates/omnigraph-policy/src/lib.rs index 6459fcd..2df616d 100644 --- a/crates/omnigraph-policy/src/lib.rs +++ b/crates/omnigraph-policy/src/lib.rs @@ -56,6 +56,18 @@ pub enum PolicyAction { /// from v0.6.0; operators add and remove graphs by editing /// `omnigraph.yaml` and restarting. GraphList, + /// Gates invoking a server-side stored query by name. Per-graph and + /// branch-scoped, like `Read`/`Change`. In this release it is + /// **coarse**: an `invoke_query` allow rule permits *any* stored + /// query on the graph (there is no per-query dimension yet). A + /// future, additive refinement adds an optional query-name scope to + /// rules without changing rules written against the coarse action. + /// + /// This gate sits at the HTTP boundary. The underlying engine `_as` + /// writers still enforce `Read`/`Change` per the stored query's body, + /// so a stored *mutation* is double-gated: `invoke_query` to reach + /// the tool, plus `change` for the write itself. + InvokeQuery, } impl PolicyAction { @@ -70,11 +82,15 @@ impl PolicyAction { Self::BranchMerge => "branch_merge", Self::Admin => "admin", Self::GraphList => "graph_list", + Self::InvokeQuery => "invoke_query", } } fn uses_branch_scope(self) -> bool { - matches!(self, Self::Read | Self::Export | Self::Change) + matches!( + self, + Self::Read | Self::Export | Self::Change | Self::InvokeQuery + ) } fn uses_target_branch_scope(self) -> bool { @@ -99,7 +115,8 @@ impl PolicyAction { | Self::BranchCreate | Self::BranchDelete | Self::BranchMerge - | Self::Admin => PolicyResourceKind::Graph, + | Self::Admin + | Self::InvokeQuery => PolicyResourceKind::Graph, } } } @@ -155,6 +172,7 @@ impl FromStr for PolicyAction { "branch_merge" => Ok(Self::BranchMerge), "admin" => Ok(Self::Admin), "graph_list" => Ok(Self::GraphList), + "invoke_query" => Ok(Self::InvokeQuery), other => bail!("unknown policy action '{other}'"), } } @@ -806,6 +824,7 @@ namespace Omnigraph { action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; + action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext }; action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext }; } @@ -1264,6 +1283,88 @@ rules: assert!(!deny.allowed); } + #[test] + fn invoke_query_authorizes_per_graph() { + let policy: PolicyConfig = serde_yaml::from_str( + r#" +version: 1 +groups: + team: [act-alice] + others: [act-bruno] +rules: + - id: team-invoke-queries + allow: + actors: { group: team } + actions: [invoke_query] +"#, + ) + .unwrap(); + let engine = PolicyCompiler::compile(&policy, "graph").unwrap(); + + let allow = engine + .authorize( + "act-alice", + &PolicyRequest { + action: PolicyAction::InvokeQuery, + branch: Some("main".to_string()), + target_branch: None, + }, + ) + .unwrap(); + assert!(allow.allowed); + assert_eq!( + allow.matched_rule_id.as_deref(), + Some("team-invoke-queries") + ); + + // Actor outside the group → deny. + let deny = engine + .authorize( + "act-bruno", + &PolicyRequest { + action: PolicyAction::InvokeQuery, + branch: Some("main".to_string()), + target_branch: None, + }, + ) + .unwrap(); + assert!(!deny.allowed); + } + + #[test] + fn invoke_query_is_branch_scoped() { + // Unlike server-scoped actions, invoke_query accepts a + // `branch_scope` qualifier — it runs against a branch like + // read/change — so validation passes and the rule authorizes. + let policy: PolicyConfig = serde_yaml::from_str( + r#" +version: 1 +groups: + team: [act-alice] +rules: + - id: team-invoke-any-branch + allow: + actors: { group: team } + actions: [invoke_query] + branch_scope: any +"#, + ) + .unwrap(); + policy.validate().unwrap(); + let engine = PolicyCompiler::compile(&policy, "graph").unwrap(); + let allow = engine + .authorize( + "act-alice", + &PolicyRequest { + action: PolicyAction::InvokeQuery, + branch: Some("review".to_string()), + target_branch: None, + }, + ) + .unwrap(); + assert!(allow.allowed); + } + #[test] fn server_scoped_rule_cannot_use_branch_scope() { let policy: PolicyConfig = serde_yaml::from_str(