Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changelog.d/graph-access-route.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- **Ask a graph what your credential may do.** `GET /graphs/{graph_id}/access`
and the MCP `access` tool return the caller's actor, the actions its
credential allows on the whole graph, and, for a named branch and target
branch, the actions allowed there, decided by the installed policy exactly
as the routes that perform them decide. See
[Learn your access][graph-access-route-guide].

[graph-access-route-guide]: ../docs/user/operations/policy.md#learn-your-access
55 changes: 55 additions & 0 deletions crates/omnigraph-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1825,6 +1825,61 @@ pub struct SnapshotQuery {
pub branch: Option<String>,
}

/// Query parameters for `GET /graphs/{graph_id}/access`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
#[serde(deny_unknown_fields)]
pub struct AccessQuery {
/// A branch whose actions to report, by name; it need not exist.
pub branch: Option<String>,
/// With `branch`, the branch `branch_create` would create and
/// `branch_merge` would merge into, by name.
pub target_branch: Option<String>,
}

/// A graph action a policy decides, as the access route reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AccessAction {
Read,
Export,
Change,
SchemaApply,
BranchCreate,
BranchDelete,
BranchMerge,
CommitPublish,
CommitDrop,
InvokeQuery,
}

/// What the caller's own credential may do on one graph, by the graph's
/// installed policy. It names actions, never the rules that allow them, and
/// says nothing about any other actor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AccessOutput {
/// The actor the credential resolves to. Absent only on a server that runs
/// without authentication.
pub actor: Option<String>,
pub graph_id: String,
/// Actions allowed with no branch named: the graph-scoped ones, and the
/// branch-scoped ones a single rule allows on every branch (a rule with no
/// branch scope, `branch_scope: any` or `target_branch_scope: any`).
/// Rules that cover every branch only together, one for protected
/// branches and one for the rest, allow each named branch but leave the
/// action out of this list; read `actions` for a branch.
pub graph_wide: Vec<AccessAction>,
Comment thread
ragnorc marked this conversation as resolved.
Resolved
/// The branch asked about, when one was.
pub branch: Option<String>,
/// The target branch asked about, when one was.
pub target_branch: Option<String>,
/// With `branch`, the actions allowed on it, including `branch_delete`,
/// `commit_publish`, `commit_drop` and `schema_apply` with it as their
/// target. With `target_branch` as well, also `branch_create` and
/// `branch_merge` from `branch` into it. Empty without `branch`.
pub actions: Vec<AccessAction>,
}

#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct CommitListQuery {
pub branch: Option<String>,
Expand Down
135 changes: 135 additions & 0 deletions crates/omnigraph-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,141 @@ pub(crate) async fn server_snapshot(
Ok(Json(output))
}

#[utoipa::path(
get,
path = "/access",
tag = "access",
operation_id = "getAccess",
params(api::AccessQuery),
responses(
(status = 200, description = "The actions the caller's credential may take", body = api::AccessOutput),
(status = 400, description = "An unknown or repeated parameter, a target branch without a branch, or a branch name that is not canonical", body = ErrorOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 500, description = "The installed policy could not be evaluated", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
/// What the caller's own credential may do on this graph.
///
/// Evaluates the graph's installed policy for the resolved actor once per
/// action, with each action's branch and target branch shaped as the route
/// that performs it shapes them. It needs no graph action itself, reads no
/// graph data, and never checks whether a named branch exists.
pub(crate) async fn server_access(
Extension(handle): Extension<GraphRequest>,
actor: Option<Extension<AuthenticatedActor>>,
query: std::result::Result<Query<api::AccessQuery>, QueryRejection>,
) -> std::result::Result<Json<api::AccessOutput>, ApiError> {
let Query(query) = query.map_err(|rejection| {
ApiError::bad_request(format!(
"invalid access query parameters: {}",
rejection.body_text()
))
})?;
Ok(Json(access_body(
&handle,
actor.as_ref().map(|Extension(actor)| actor),
query,
)?))
}

/// The access route's answer, shared by `GET /access` and the MCP `access`
/// tool.
pub(crate) fn access_body(
handle: &GraphRequest,
actor: Option<&AuthenticatedActor>,
query: api::AccessQuery,
) -> std::result::Result<api::AccessOutput, ApiError> {
use api::AccessAction as A;
if query.target_branch.is_some() && query.branch.is_none() {
return Err(ApiError::bad_request(
"target_branch needs branch: it names where branch_create and branch_merge from branch would land",
));
}
require_canonical_branches([query.branch.as_deref(), query.target_branch.as_deref()])?;
let policy = handle.policy.as_deref();
let allowed = |access: A,
branch: Option<&str>,
target_branch: Option<&str>|
-> std::result::Result<bool, ApiError> {
let request = PolicyRequest {
action: access_policy_action(access),
branch: branch.map(str::to_string),
target_branch: target_branch.map(str::to_string),
};
Ok(matches!(authorize(actor, policy, request)?, Authz::Allowed))
};

let mut graph_wide = Vec::new();
for access in [
A::Read,
A::Export,
A::Change,
A::SchemaApply,
A::BranchCreate,
A::BranchDelete,
A::BranchMerge,
A::CommitPublish,
A::CommitDrop,
A::InvokeQuery,
] {
if allowed(access, None, None)? {
graph_wide.push(access);
}
}

let mut actions = Vec::new();
if let Some(branch) = query.branch.as_deref() {
for access in [A::Read, A::Export, A::Change] {
if allowed(access, Some(branch), None)? {
actions.push(access);
}
}
for access in [
A::SchemaApply,
A::BranchDelete,
A::CommitPublish,
A::CommitDrop,
] {
if allowed(access, None, Some(branch))? {
actions.push(access);
}
}
if let Some(target) = query.target_branch.as_deref() {
for access in [A::BranchCreate, A::BranchMerge] {
if allowed(access, Some(branch), Some(target))? {
actions.push(access);
}
}
}
}

Ok(api::AccessOutput {
actor: actor.map(|actor| actor.actor_id.to_string()),
graph_id: handle.key.graph_id.to_string(),
graph_wide,
branch: query.branch,
target_branch: query.target_branch,
actions,
})
}

fn access_policy_action(access: api::AccessAction) -> PolicyAction {
use api::AccessAction as A;
match access {
A::Read => PolicyAction::Read,
A::Export => PolicyAction::Export,
A::Change => PolicyAction::Change,
A::SchemaApply => PolicyAction::SchemaApply,
A::BranchCreate => PolicyAction::BranchCreate,
A::BranchDelete => PolicyAction::BranchDelete,
A::BranchMerge => PolicyAction::BranchMerge,
A::CommitPublish => PolicyAction::CommitPublish,
A::CommitDrop => PolicyAction::CommitDrop,
A::InvokeQuery => PolicyAction::InvokeQuery,
}
}

#[utoipa::path(
post,
path = "/query",
Expand Down
2 changes: 2 additions & 0 deletions crates/omnigraph-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash {
handlers::server_graphs_list,
handlers::server_graphs_discovery,
handlers::server_snapshot,
handlers::server_access,
handlers::server_blob_get,
handlers::server_blob_head,
handlers::server_blob_put,
Expand Down Expand Up @@ -2723,6 +2724,7 @@ pub fn build_app(state: AppState) -> Router {
// `{graph_id}` in the URI path).
let per_graph_protected = Router::new()
.route("/snapshot", get(server_snapshot))
.route("/access", get(server_access))
// Register HEAD explicitly. Axum's GET fallback would invoke the GET
// handler and could begin payload work before stripping the body; the
// dedicated handler makes the zero-payload-read contract structural.
Expand Down
63 changes: 63 additions & 0 deletions crates/omnigraph-server/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,15 @@ struct BranchArguments {
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccessArguments {
graph: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
target_branch: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CommitsListArguments {
graph: String,
#[serde(default)]
Expand Down Expand Up @@ -992,6 +1001,19 @@ impl GraphTools {
.await?;
bounded_result(&output.0)
}
"access" => {
let args: AccessArguments = arguments_as(arguments)?;
let (handle, actor) = self.graph(&args.graph, actor, &graph)?;
let output = handlers::access_body(
&handle,
Some(&actor),
crate::api::AccessQuery {
branch: args.branch,
target_branch: args.target_branch,
},
)?;
bounded_result(&output)
}
"branches_list" => {
let args: GraphArguments = arguments_as(arguments)?;
let (handle, actor) = self.graph(&args.graph, actor, &graph)?;
Expand Down Expand Up @@ -1421,6 +1443,15 @@ const TOOLS: &[ToolSpec] = &[
effect: Effect::Read,
gate: Gate::Always,
},
ToolSpec {
name: "access",
title: "Your access",
description: "Return what your credential may do on a graph: your `actor`, the actions allowed whatever the branch (`graph_wide`), and, for a `branch` you name, the actions allowed on it (`actions`). Name a `target_branch` too to learn whether you may create it from `branch` or merge `branch` into it. Check this before a write instead of learning from a refusal. Branches are judged by name and need not exist.",
input: access_schema,
output: Some(access_output_schema),
effect: Effect::Read,
gate: Gate::Always,
},
ToolSpec {
name: "branches_list",
title: "List branches",
Expand Down Expand Up @@ -1620,6 +1651,16 @@ fn branch_schema() -> Value {
)
}

fn access_schema() -> Value {
arguments(
json!({
"branch": text_schema("A branch whose actions to report."),
"target_branch": text_schema("With `branch`, the branch it would be created into or merged into."),
}),
&[],
)
}

fn commits_list_schema() -> Value {
arguments(
json!({
Expand Down Expand Up @@ -1789,6 +1830,28 @@ fn graphs_output_schema() -> Value {
})
}

fn access_output_schema() -> Value {
let actions = json!({
"type": "array",
"items": {"type": "string", "enum": [
"read", "export", "change", "schema_apply", "branch_create", "branch_delete",
"branch_merge", "commit_publish", "commit_drop", "invoke_query"
]},
});
json!({
"type": "object",
"properties": {
"actor": {"type": ["string", "null"]},
"graph_id": {"type": "string"},
"graph_wide": actions,
"branch": {"type": ["string", "null"]},
"target_branch": {"type": ["string", "null"]},
"actions": actions,
},
"required": ["actor", "graph_id", "graph_wide", "branch", "target_branch", "actions"],
})
}

fn branches_output_schema() -> Value {
json!({
"type": "object",
Expand Down
Loading
Loading