Add InvokeQuery Cedar action (coarse, graph-scoped)

A per-graph, branch-scoped action that gates invoking a server-side
stored query by name. Coarse for now: an `invoke_query` allow rule
permits any stored query on the graph; a future, additive refinement
adds an optional per-query-name scope without changing rules written
against the coarse action. Enforcement is at the HTTP boundary; the
engine `_as` writers still enforce read/change per the query body, so a
stored mutation is double-gated (invoke_query to reach the tool, change
for the write). No call site yet — the invocation handler wires it in a
later change (same pattern as Admin/GraphList added ahead of consumers).

- variant + as_str/resource_kind(Graph)/FromStr/uses_branch_scope
- Cedar schema: invoke_query appliesTo Graph
- tests: per-graph allow/deny, branch-scope accepted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnor Comerford 2026-05-30 20:20:03 +02:00
parent 3419512846
commit 6a16b3c6ac
No known key found for this signature in database

View file

@ -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(