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!()
}
}
context
is the shared context object. We can store data in it in theon_gateway_request
hook.arguments
has all the data needed to authorize the edge. Read more about the EdgePreExecutionArguments struct.
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!()
}
}
context
is the shared context object. We can store data in it in theon_gateway_request
hook.arguments
has all the data needed to authorize the node. Read more about the NodePreExecutionArguments struct.
#[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!()
}
...
}
context
is the shared context object. We can store data in it in theon_gateway_request
hook.arguments
has all the data needed to authorize the edges. Read more about the EdgeNodePostExecutionArguments struct.
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!()
}
}
context
is the shared context object. We can store data in it in theon_gateway_request
hook.arguments
has all the data needed to authorize the edges. Read more about the ParentEdgePostExecutionArguments struct.
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.