Authorizing Data Access with Hooks

Control access to your data based on the data itself. For example, you can restrict access to specific fields or rows based on user roles, or control input parameter use based on custom logic. Use hooks to implement custom authorization logic.

Grafbase Gateway requires custom logic for data authorization. This feature combines an @authorized directive with an authorization hook. Use the @authorized directive to define where in the schema access the engine calls the authorization hook. A Rust function acts as the hook and checks whether the user can access the data.

First, check out our hooks guide to learn the basics of custom hook implementation.

Gateway first calls the on_gateway_request hook as the entry point for data authorization before processing any request. This hook accepts request headers and provides mutable access to the context object. All later hooks share this context object in an immutable form.

In this example, take the x-current-user-id header value and store it in the context object.

struct MyHooks; #[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { fn new() -> Self where Self: Sized, { Self } fn on_gateway_request( &mut self, context: grafbase_hooks::Context, headers: grafbase_hooks::Headers, ) -> Result<(), grafbase_hooks::ErrorResponse> { if let Some(user_id) = headers.get("x-current-user-id") { context.set("current_user_id", &user_id); } Ok(() } } grafbase_hooks::register_hooks!(MyHooks);

The engine authorizes an edge before executing the resolver function if you add the @authorized directive that has the arguments directive to the edge definition:

type Query { user(id: ID): User @authorized(arguments: "id") }

The directive expects the following hook to be defined:

#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { fn authorize_edge_pre_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgePreExecutionArguments, ) -> Result<(), grafbase_hooks::Error> { todo!() } }

In the schema we define the arguments as id, so the arguments argument in the hook will be a JSON-formatted string with the id field. So if we query the user with the id of 1:

query { user(id: 1) { id } }

The directive defines id as the arguments, which you can access from the arguments method in the EdgePreExecutionArguments struct. The raw arguments will be:

{ "id": 1 }

Define a struct in the hook file and use Serde to deserialize the JSON string into it. Then parse the arguments and compare the context's user ID with the ID in the arguments.

#[serde::Deserialize, Debug] struct Arguments { id: i64, } #[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { ... fn authorize_edge_pre_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgePreExecutionArguments, ) -> Result<(), grafbase_hooks::Error> { // Get the user ID from the context let Some(user_id) = context.get("current_user_id") else { return Err(grafbase_hooks::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), }); } // Parse the user ID string to an integer let Ok(user_id) = user_id.parse::<i64>() else { return Err(grafbase_hooks::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), }); } // Parse the arguments let arguments: Arguments = arguments.arguments().unwrap(); // Check if the user ID matches the ID in the arguments if user_id != arguments.id { return Err(grafbase_hooks::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), }); } // Return Ok if the user is authorized Ok(()) } }

When we send the query again:

query { user(id: 1) { id } }

The engine expects a header with the x-current-user-id key and the value of 1. If the header is missing or the value is different, the engine returns an error.

The engine authorizes a node before executing the resolver function if you add the @authorized directive to the node definition:

type Query { user(id: ID): User } type User @authorized { id: ID }

The directive expects the following hook to be defined:

#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { ... fn authorize_node_pre_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::NodePreExecutionArguments, ) -> Result<(), grafbase_hooks::Error> { todo!() } }
#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { ... fn authorize_node_pre_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::NodePreExecutionArguments, ) -> Result<(), grafbase_hooks::Error> { if arguments.type_name() == "User" && context.get("current_user_id").is_none() { return Err(grafbase_hooks::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), }); } Ok(()) } }

The engine requires a header with the x-current-user-id key. The engine will return an error if the header is missing when a query attempts to access the User node either directly or through a field:

query { user(id: 1) { id } }

or

query { blogs { # The author field is a User node author { id } } }

The engine authorizes an edge after executing the resolver function if you add the @authorized directive that has the node argument to the edge definition:

type Query { users: [User!]! @authorized(node: "id") }

Or in the node definition:

type Query { users: [User!]! } type User @authorized(node: "id") { id: ID! }

The directive expects the following hook to be defined:

#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for MyHooks { fn authorize_edge_node_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgeNodePostExecutionArguments, ) -> Vec<Result<(), grafbase_hoks::Error>> { todo!() } ... }

To parse the nodes or metadata, we need to use the serde crate.

cargo add serde --features derive

In the schema we define the node as id, so every node in the users field will be a JSON-formatted string with the id field. So if we query the users:

query { users { id } }

The nodes vector will be:

[ { "id": 1 }, { "id": 2 }, ... ]

Define a struct in the hook file and use Serde to deserialize the JSON strings into it. Then parse the arguments and compare the context's user ID with the ID in the arguments.

#[serde::Deserialize, Debug] struct Node { id: i64, } #[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for Component { fn authorize_edge_node_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgeNodePostExecutionArguments, ) -> Vec<Result<(), grafbase_hooks::Error>> { let Some(user_id) = context.get("current_user_id") else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let Ok(user_id) = user_id.parse::<i64>() else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let nodes: Vec<Node> = arguments.nodes().unwrap(); let mut result = Vec::with_capacity(nodes.len()); for node in nodes { if user_id != node.id { result.push(Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })); } else { result.push(Ok(())); } } result } ... }

The hook must returns a vector of results with the same length as the nodes vector, or a vector of one item which will be repeated for every edge by the gateway. If the user is authorized, the result is Ok(()). If the user is not authorized, the result is Err(authorization::Error). The engine will filter out the unauthorized nodes from the response.

So if we query the users:

query { users { id } }

And send the x-current-user-id header with the value of 1, the engine will return the user with the ID of 1. If the header is missing or the value is different, the engine will return nothing.

The engine authorizes a parent edge after executing the resolver function by using data from the parent node if you add the @authorized directive that has the fields argument to the edge definition:

type User { id: ID! name: String! @authorized(fields: "id") } type Query { users: [User!]! }

The directive expects the following hook to be defined:

#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for Component { ... fn authorize_parent_edge_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::ParentEdgePostExecutionArguments, ) -> Vec<Result<(), grafbase_hooks::Error>> { todo!() } }

To parse the nodes or metadata, we need to use the serde crate.

cargo add serde --features derive

In the schema we define the fields as id, so every node in the users field will be a JSON-formatted string with the id field. So if we query the users and access their name:

query { users { name } }

The fields vector will be:

[ { "id": 1 }, { "id": 2 }, ... ]

Define a struct in the hook module and use Serde to deserialize the JSON strings into it. Then parse the arguments and compare the context's user ID with the ID in the arguments.

#[serde::Deserialize, Debug] struct Fields { id: i64, } #[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for Component { fn authorize_parent_edge_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::ParentEdgePostExecutionArguments, ) -> Vec<Result<(), grafbase_hooks::Error>> { let Some(user_id) = context.get("current_user_id") else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let Ok(user_id) = user_id.parse::<i64>() else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let fields: Vec<Fields> = arguments.fields().unwrap(); let mut result = Vec::with_capacity(fields.len()); for fields in fields { if user_id != fields.id { result.push(Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })); } else { result.push(Ok(())); } } result } ... }

The hook must returns a vector of results with the same length as the fields vector, or a vector of one item which will be repeated for every edge by the gateway. If the user is authorized, the result is Ok(()). If the user is not authorized, the result is Err(authorization::Error). The engine will filter out the unauthorized nodes from the response.

So if we query the user names:

query { users { name } }

And send the x-current-user-id header with the value of 1, the engine will return the user with the ID of 1. If the header is missing or the value is different, the engine will return nothing.

The engine authorizes an edge by providing selected field from the nodes returned by the edge together with fields from the parent node, after executing the resolver function by using data from the parent node if you add the @authorized directive that has the fields and node arguments to the edge definition:

type Address { street: String! } type User { id: ID! addresses: [Address!]! @authorized(fields: "street", node: "id") } type Query { users: [User!]! }

The directive expects the following hook to be defined:

#[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for Component { ... fn authorize_edge_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgePostExecutionArguments, ) -> Vec<Result<(), grafbase_hooks::Error>> { todo!() } }

In the schema we define the fields as street and node as id, so we get a vector of tuples where the left side is the serialized data from the parent node and the right side a vector of serialized data from the address entities. So if we query the users and their addresses:

```graphql query { users { addresses { street } } }

The nodes will be:

[ { "id": 1 }, { "id": 2 }, ... ]

and for the first node the fields will be:

[ { "street": "123 Main St." }, { "street": "456 Elm St." }, ... ] for the second node: ```json [ { "street": "789 Oak St." }, { "street": "101 Pine St." }, ... ]

Define structs in the hook module and use Serde to deserialize the JSON strings into it. Then parse the arguments and compare the context's user ID with the ID in the arguments.

#[serde::Deserialize, Debug] struct Parent { id: u64, } #[serde::Deserialize, Debug] struct Fields { street: String, } #[grafbase_hooks::grafbase_hooks] impl grafbase_hooks::Hooks for Component { ... fn authorize_edge_post_execution( &mut self, context: grafbase_hooks::SharedContext, arguments: grafbase_hooks::EdgePostExecutionArguments, ) -> Vec<Result<(), grafbase_hooks::Error>> { let Some(user_id) = context.get("current_user_id") else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let Ok(user_id) = user_id.parse::<i64>() else { return vec![Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })]; }; let edges: Vec<(Parent, Vec<Edge>)> = match arguments.edges().unwrap(); for (parent, nodes) in edges { if user_id != parent.id { result.push(Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })); continue; } if nodes.iter().any(|node| "101 Pine St." != &node.street) { result.push(Err(authorization::Error { message: "Unauthorized".to_string(), extensions: Vec::new(), })); continue; } result.push(Ok(()) } result } }

The hook must returns a vector of results with the same length as the fields vector, or a vector of one item which will be repeated for every edge by the gateway. If the user is authorized, the result is Ok(()). If the user is not authorized, the result is Err(authorization::Error), and if any of the streets for a user is 101 Pine St. the engine will filter out the unauthorized nodes from the response.

So if we query the user addresses:

query { users { addresses { street } } }

And send the x-current-user-id header with the value of 1, the engine will return the user with the ID of 1. If the header is missing or the value is different, the engine will return nothing.

Quite often you have an external service for authorization, and you need to make a HTTP request to it to check if the user is authorized. You can use the host_io::http module to make the needed requests.