Contextual tuples are relationships and attributes that you send inline with a permission check request. They are processed together with the persisted data in the database and influence the check result — but they are never written to storage. When the request completes, they disappear.
This makes contextual tuples ideal for:
- Dynamic context: data that changes per request (IP address, time of day, network location) and cannot be stored as static relationships.
- “What-if” scenarios: testing whether a permission would be granted if a relationship existed, without actually creating it.
- Session-level data: attaching short-lived context such as a user’s current role or active session attributes to a single check.
How contextual tuples are evaluated
When the check engine processes a request, it merges the contextual tuples with the results from the database query. The NewContextualTuples function in internal/storage/context/tuples.go creates an in-memory tuple iterator from the tuples provided in context.tuples. That iterator is combined with the database iterator via NewUniqueTupleIterator, ensuring duplicates are deduplicated before the check logic runs.
The same pattern applies to attributes: NewContextualAttributes in internal/storage/context/attributes.go handles the context.attributes field.
Example: IP-based access control
Consider an internal HR application where an employee can view another employee’s details only if they are an HR manager and are connected through the branch’s internal network. The network address is a dynamic value — it changes per request and cannot be modelled as a static relation.
Authorization model
entity user {}
entity organization {
relation employee @user
relation hr_manager @user @organization#employee
relation ip_address_range @ip_address_range
action view_employee = hr_manager and ip_address_range.user
}
entity ip_address_range {
relation user @user
}
The ip_address_range entity type represents the contextual variable. The view_employee action requires the user to be an HR manager and have a relation through the ip_address_range entity.
Because ip_address_range is dynamic, you cannot write it as a static tuple. Instead, you pass it at check time.
Access check with contextual tuples
Assume:
- User
1 has the tuple organization:1#hr_manager@user:1 stored in the database.
- User
1 is connecting from IP 192.158.1.38, which belongs to the branch’s internal network.
curl --location --request POST 'localhost:3476/v1/tenants/t1/permissions/check' \
--header 'Content-Type: application/json' \
--data-raw '{
"metadata": {
"snap_token": "",
"schema_version": "",
"depth": 20
},
"entity": {
"type": "organization",
"id": "1"
},
"permission": "view_employee",
"subject": {
"type": "user",
"id": "1",
"relation": ""
},
"context": {
"tuples": [
{
"entity": { "type": "ip_address_range", "id": "192.158.1.38" },
"relation": "user",
"subject": { "type": "user", "id": "1", "relation": "" }
}
]
}
}'
cr, err := client.Permission.Check(context.Background(), &v1.PermissionCheckRequest{
TenantId: "t1",
Metadata: &v1.PermissionCheckRequestMetadata{
SnapToken: "",
SchemaVersion: "",
Depth: 20,
},
Entity: &v1.Entity{
Type: "organization",
Id: "1",
},
Permission: "view_employee",
Subject: &v1.Subject{
Type: "user",
Id: "1",
},
Context: &v1.Context{
Tuples: []*v1.Tuple{
{
Entity: &v1.Entity{Type: "ip_address_range", Id: "192.158.1.38"},
Relation: "user",
Subject: &v1.Subject{Type: "user", Id: "1"},
},
},
},
})
if cr.Can == v1.CheckResult_CHECK_RESULT_ALLOWED {
// access granted
}
client.permission
.check({
tenantId: "t1",
metadata: {
snapToken: "",
schemaVersion: "",
depth: 20,
},
entity: {
type: "organization",
id: "1",
},
permission: "view_employee",
subject: {
type: "user",
id: "1",
},
context: {
tuples: [
{
entity: { type: "ip_address_range", id: "192.158.1.38" },
relation: "user",
subject: { type: "user", id: "1" },
},
],
},
})
.then((response) => {
if (response.can === PermissionCheckResponse_Result.RESULT_ALLOWED) {
console.log("RESULT_ALLOWED");
}
});
import permify
from permify.models.permission_check_request import PermissionCheckRequest
from permify.models.permission_check_response import PermissionCheckResponse
from permify.rest import ApiException
configuration = permify.Configuration(host="http://localhost")
with permify.ApiClient(configuration) as api_client:
api_instance = permify.PermissionApi(api_client)
tenant_id = "t1"
body = PermissionCheckRequest(
tenant_id=tenant_id,
metadata={
"snapToken": "",
"schemaVersion": "",
"depth": 20,
},
entity={"type": "organization", "id": "1"},
permission="view_employee",
subject={"type": "user", "id": "1"},
context={
"tuples": [
{
"entity": {"type": "ip_address_range", "id": "192.158.1.38"},
"relation": "user",
"subject": {"type": "user", "id": "1", "relation": ""},
}
]
},
)
try:
api_response = api_instance.permissions_check(tenant_id, body)
if api_response.can == PermissionCheckResponse.Result.RESULT_ALLOWED:
print("RESULT_ALLOWED")
else:
print("RESULT_DENIED")
except ApiException as e:
print(f"Exception: {e}")
The context field
The context object accepted by Check, LookupEntity, and LookupSubject requests supports two sub-fields:
| Field | Type | Description |
|---|
context.tuples | array of tuples | Temporary relationship tuples merged with database results during evaluation. Not persisted. |
context.attributes | array of attributes | Temporary attribute values merged with database results during evaluation. Not persisted. |
context.data | object | Arbitrary key-value data accessible inside CEL rule expressions via context.data. |
Contextual tuples are evaluated in memory during the request and are never written to the relation_tuples table. They have no effect on future requests unless you send them again.