services:
heimdall: (1)
image: dadrus/heimdall:dev
container_name: heimdall
ports:
- "9090:4456"
volumes:
- ./heimdall-config.yaml:/etc/heimdall/config.yaml:ro
- ./rules:/etc/heimdall/rules:ro
- ./signer.pem:/etc/heimdall/signer.pem:ro
command: serve proxy -c /etc/heimdall/config.yaml --insecure
upstream: (2)
image: traefik/whoami:latest
container_name: upstream
command:
- --port=8081
openfga: (3)
image: openfga/openfga:latest
container_name: openfga
command: run
ports:
- "8080:8080"
idp: (4)
image: nginx:1.25.4
volumes:
- ./idp.nginx:/etc/nginx/nginx.conf:ro
- ./jwks.json:/var/www/nginx/jwks.json:ro
Integration with OpenFGA
This guide explains how to integrate heimdall with the OpenFGA, a scalable open source authorization system.
OpenFGA is inspired by Google’s Zanzibar, Google’s Relationship Based Access Control system and allows easily implementing authorization for any kind of application relying on Role-Based Access Control with additional Attribute-Based Access Control capabilities.
This guide is based on the OpenFGA’s official Getting Started guide and highlights the relevant differences. It mimics a RESTful document management API, allowing listing, creating, reading, updating, and deleting documents. The API consists of two endpoints:
/document/<id>
withid
being the id of the document and the HTTP verbs reflecting the actual operations. E.g. aGET /document/1234
request should read the document with the id1234
./documents
which supports the HTTPGET
verb only and allows the user to list the documents it can access.
The identity of the user will be taken from a JWT.
Even this guide addresses OpenFGA, it actually covers integration with any ReBAC system, be it Ory’s Keto, SpiceDB from AuthZed, or any other system inspired by Google’s Zanzibar. |
Prerequisites
To be able to follow this guide, you’ll need the following tools installed locally:
docker-compose, and
a text editor of your choice.
Configure the Base Setup
Create a directory in which we’re going to create further directories and files required for the setup and switch to it.
Create a directory named
rules
. We’ll add the heimdall rule to it after our setup is started.Create a
docker-compose.yaml
file with the following contents1 These lines configure heimdall to use a config file, we’re going to configure next and a rule directory, we’ll add our rules to. We’re using the --insecure
flag here to simplify our setup, which disables enforcement of some security settings you can learn about more here.2 Here, we define the "upstream" service, which just echoes back everything it receives. 3 And these lines configure our OpenFGA instance. It is the simplest setup, described in Setup OpenFGA with Docker 4 This is an NGINX service, which mimics an IDP system and exposes an JWKS endpoint with key material, heimdall is going to use to validate the JWT you’re going to use. Create a configuration file for heimdall, named
heimdall-config.yaml
with the following contents.mechanisms: authenticators: - id: jwt_auth (1) type: jwt config: jwks_endpoint: http://idp:8080/.well-known/jwks assertions: issuers: - demo_issuer authorizers: - id: openfga_check (2) type: remote config: endpoint: http://openfga:8080/stores/{{ .Values.store_id }}/check (3) payload: | (4) { "authorization_model_id": "{{ .Values.model_id }}", "tuple_key": { "user": "user:{{ .Subject.ID }}", "relation":"{{ .Values.relation }}", "object":"{{ .Values.object }}" } } expressions: - expression: | Payload.allowed == true (5) contextualizers: - id: openfga_list (6) type: generic config: endpoint: http://openfga:8080/stores/{{ .Values.store_id }}/list-objects (7) payload: | (8) { "authorization_model_id": "{{ .Values.model_id }}", "user": "user:{{ .Subject.ID }}", "relation":"{{ .Values.relation }}", "type":"document" } finalizers: - id: create_jwt (9) type: jwt config: signer: key_store: path: /etc/heimdall/signer.pem providers: file_system: (10) src: /etc/heimdall/rules watch: true
1 This and the following lines define and configure the jwt
authenticator namedjwt_auth
. With the given configuration it will check whether a request contains anAuthorization
header with a bearer token in JWT format and validate it using key material fetched from the JWKS endpoint.2 Here we define and configure a remote
authorizer namedopenfga_check
, which we’re going to use for the actual authorization purposes in our rules.3 Here we define the endpoint to be used for the authorization checks. Most probably, you’ll want to hard code your OpenFGA model id. Since, we’re going to create the model, when we start our setup, we’ll reference it in our rule via store_id
.We use a very simple endpoint
configuration here by just specifying the actual url. If required, you can specify API keys, and many more. Take a look at the linked documentation of this property.4 This is the definition of our payload to be sent to the check endpoint. As we don’t know the model id as well, we’ll configure it in our rule. The user will be taken from the Subject
create by heimdall, and the relation and object will be specified in our rule.5 In case of a successful response, the response from the check endpoint will look like {"allowed": true}
. Otherwise, it will be{"allowed": false}
. With the expression here, we perform the required verification.6 Here we define and configure a generic
contextualizer namedopenfga_list
.7 As with the authorization mechanism, defined above, here we configure the endpoint to list the allowed objects. 8 The payload configuration used while communicating to the configured endpoint. 9 The following two lines define the jwt
finalizer. With the given configuration, it will create a jwt out of the subject object with standard claims and set thesub
claim to the value of subject’s ID.10 The last few lines of the configure the file_system
provider, which allows loading of regular rules from the file system.Create a file, named
signer.pem
with the following content. This is our key store with a private key, you’ve seen in the configuration above.-----BEGIN EC PRIVATE KEY----- MIGkAgEBBDALv/dRp6zvm6nmozmB/21viwFCUGBoisHz0v8LSRXGiM5aDywLFmMy 1jPnw29tz36gBwYFK4EEACKhZANiAAQgZkUS7PCh5tEXXvZk0LDQ4Xn4LSK+vKkI zlCZl+oMgud8gacf4uG5ERgju1xdUyfewsXlwepTnWuwhXM7GdnwY5GOxZTwGn3X XVwR/5tokqFVrFxt/5c1x7VdccF4nNM= -----END EC PRIVATE KEY-----
Do not use it for purposes beyond this tutorial! Configure NGINX to expose a static endpoint serving a JWKS document under the
.well-known
path, so heimdall is able to verify the JWT, we’re going to use. Create a file namedidp.nginx
with the following content:worker_processes 1; user nginx; pid /var/run/nginx.pid; events { worker_connections 1024; } http { keepalive_timeout 65; server { listen 8080; location /.well-known/jwks { default_type application/json; root /var/www/nginx; try_files /jwks.json =404; } } }
In addition, create a file named
jwks.json
with the public key required to verify the tokens we’re going to use.{ "keys": [ { "use": "sig", "kty": "EC", "kid": "key-2", "crv": "P-256", "alg": "ES256", "x": "NnU0iWRq7szZP_8Ir3D4BShUEtcW1dHpuvlCgB6ecE0", "y": "X71tZm51ovUPFNKE0bsi5XF-FtIykEfk1O83EHNkSdo" } ] }
Create Authorization Model & Rules
The static configuration of our services is in place. Let us now create the actual authorization model and based on it the required heimdall rules.
Start our setup with
docker compose up
and wait until all services are up and running.Create the OpenFGA store as also described in Create Store with
curl -X POST http://127.0.0.1:8080/stores \ -H "content-type: application/json" \ -d '{"name": "FGA Demo Store"}'
This call should result in an output similar to
{ "id":"01HSXG2XSZJMQG99EVXB4QQX8P", "name":"FGA Demo Store", "created_at":"2024-03-26T13:44:37.439559338Z", "updated_at":"2024-03-26T13:44:37.439559338Z" }
Note or write down the value of the store
id
returned.Configure the authorization model as also described in Configure Model with
curl -X POST http://127.0.0.1:8080/stores/<the id from above>/authorization-models \ -H "content-type: application/json" \ -d '{"schema_version":"1.1","type_definitions":[{"type":"user"},{"type":"document","relations":{"reader":{"this":{}},"writer":{"this":{}},"owner":{"this":{}}},"metadata":{"relations":{"reader":{"directly_related_user_types":[{"type":"user"}]},"writer":{"directly_related_user_types":[{"type":"user"}]},"owner":{"directly_related_user_types":[{"type":"user"}]}}}}]}'
This call should result in an output similar to
{ "authorization_model_id":"01HSXG7TBQEJ7GBPKQR2VYH24G" }
Note or write down the value of
authorization_model_id
.Let us now create a rule set for heimdall. Create a file named
demo.yaml
with the following contents in therules
directoryversion: "1alpha4" rules: - id: access_document (1) match: routes: - path: /document/:id (2) methods: [ GET, POST, DELETE ] forward_to: (3) host: upstream:8081 execute: - authenticator: jwt_auth (4) - authorizer: openfga_check (5) config: values: store_id: 01HSXG2XSZJMQG99EVXB4QQX8P (6) model_id: 01HSXG7TBQEJ7GBPKQR2VYH24G (7) relation: > (8) {{- if eq .Request.Method "GET" -}} reader {{- else if eq .Request.Method "POST" -}} creator {{- else if eq .Request.Method "DELETE" -}} deleter {{- else -}} unknown {{- end -}} object: > document:{{- .Request.URL.Captures.id -}} (9) - finalizer: create_jwt (10) - id: list_documents (11) match: routes: - path: /documents (12) methods: [ GET ] (14) forward_to: (13) host: upstream:8081 execute: (15) - authenticator: jwt_auth - contextualizer: openfga_list config: values: store_id: 01HSXG2XSZJMQG99EVXB4QQX8P model_id: 01HSXG7TBQEJ7GBPKQR2VYH24G relation: reader - finalizer: create_jwt config: claims: | {{ toJson .Outputs.openfga_list }} (16)
1 Our rule set consists of two rules. The first one has the id access_document
2 This rule should match urls of the following form /document/<id>
, with id being the identifier of a document.3 If the execution of the authentication & authorization pipeline was successful, the request should be forwarded to the upstream:8081
host.4 The authentication & authorization pipeline starts with the reference to the previously defined authenticator jwt_auth
5 Next, we specify the openfga_check
authorizer and also configure the rule specific settings6 Replace the value here with the store id, you’ve received in step 6 7 Replace the value here with the authorization model id, you’ve received in step 7 8 Here, we set the relation depending on the used HTTP request method 9 Our object reference. We use the value captured by the wildcard named id
.10 Reference to the previously configured finalizer to create a JWT to be forwarded to our upstream service 11 This is our second rule. It has the id list_documents
.12 And matches any request of the form /documents
13 As the previous rule, this one forwards the request to the upstream:8081
host on successful completion of the authentication & authorization pipeline14 Unlike the access_document
rule, this one allows only HTTP GET methods for the matched urls.15 The authentication & authorization pipeline is pretty similar to the previous rule. The main difference is the usage of the openfga_list
contextualizer instead of theopenfga_check
authorizer and the reconfiguration of thecreate_jwt
finalizer. As with the previous rule, replace thestore_id
andmodel_id
with the values, you’ve received above.16 Here, we reconfigure our finalizer to include the results from the openfga_list
contextualizer into the created JWT.
Update Relationship Tuples
Having everything in place, time to configure the actual permissions. As with the previous steps, this one is based on Update Relationship Tuples from the official OpenFGA guide. So, let us give our user anne
at least the read
permission.
If you skip this step and directly continue with Use the Setup, you’ll always receive a 403 Forbidden response. |
Call the OpenFGA write endpoint as also described in Calling Write API To Add New Relationship Tuples to create a reader relationship between our user
anne
and the document with the id1234
. Replace the store id and the authorization model id with those, you’ve received while following the steps above:curl -X POST http://127.0.0.1:8080/stores/<the store id from above>/write \ -H "content-type: application/json" \ -d '{ "authorization_model_id": "<the authorization model id from above>", "writes": { "tuple_keys" : [ { "user":"user:anne", "relation":"reader", "object":"document:1234" } ] } }'
Verify
anne
has the required permissionscurl -X POST http://127.0.0.1:8080/stores/<the store id from above>/check \ -H "content-type: application/json" \ -d '{ "authorization_model_id": "<the authorization model id from above>", "tuple_key": { "user": "user:anne", "relation": "reader", "object": "document:1234" } }'
You should receive the following response:
{"allowed":true, "resolution":""}
Use the Setup
We have now definitely everything in place to allow our user anne
to at least read the document with the id 1234
and also list the documents anne
has access to.
Try executing the following command:
$ curl -X GET -H "Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0yIiwidHlwIjoiSldUIn0.eyJleHAiOjIwMjcyMzUxODUsImlhdCI6MTcxMTg3NTE4NSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJqdGkiOiI1ZDJjM2E3OC1hM2Y5LTRlNmYtOTExYi0xZjZmZWQ5ODE3YTciLCJuYmYiOjE3MTE4NzUxODUsInN1YiI6ImFubmUifQ.wH7HOs-w8YbsOLJcZ9bHBuY5lCBZmYUhQGLJyEbePJZ_WlyR7aa0QmCc3Yx9JsSs3HDmnIbD2wUaFTe2rZWtqA" \ 127.0.0.1:9090/document/1234
You should see an output similar to the one shown below. Since our upstream does just echo everything back it receives, it represents a successful response to read the document with the id
1234
.Hostname: 94e60bba8498 IP: 127.0.0.1 IP: 172.19.0.2 RemoteAddr: 172.19.0.4:43688 GET /admin HTTP/1.1 Host: upstream:8081 User-Agent: curl/8.2.1 Accept: */* Accept-Encoding: gzip Authorization: Bearer eyJhbGciOiJFUzM4NCIsImtpZCI6ImIzNDA3N2ZlNWI5NDczYzBjMmY3NDNmYWQ0MmY3ZDU0YWM3ZTFkN2EiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3MTg2OTQ5MzAsImlhdCI6MTcxODY5NDYzMCwiaXNzIjoiaGVpbWRhbGwiLCJqdGkiOiJiNzgyZGE4YS1mMDFlLTRmYmUtYTlkZC04MzdiYzYzYzlhODUiLCJuYmYiOjE3MTg2OTQ2MzAsInN1YiI6ImFubmUifQ.xANlIPmRWdMraL_j0i-0cK4NVhqopzgSc5_u0m4Hyg4VAFQ3ZHuuap1ZD9hs8ZkBQGin9-vPsBeVrQr40OfAev7WKyNVPpIpmFBAU8fX15kXgVXox29kgBAcAM2b2W-w Forwarded: for=172.19.0.1;host=127.0.0.1:9090;proto=http
Let us list the documents our user has access to
$ curl -H "Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0yIiwidHlwIjoiSldUIn0.eyJleHAiOjIwMjcyMzUxODUsImlhdCI6MTcxMTg3NTE4NSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJqdGkiOiI1ZDJjM2E3OC1hM2Y5LTRlNmYtOTExYi0xZjZmZWQ5ODE3YTciLCJuYmYiOjE3MTE4NzUxODUsInN1YiI6ImFubmUifQ.wH7HOs-w8YbsOLJcZ9bHBuY5lCBZmYUhQGLJyEbePJZ_WlyR7aa0QmCc3Yx9JsSs3HDmnIbD2wUaFTe2rZWtqA" \ 127.0.0.1:9090/documents
You should again see an output similar to the one shown below. However, if you take a closer look at the JWT from the
Authorization
header by e.g. making use of https://www.jstoolset.com/jwt, you’ll see it contains also a list of documentsanne
has access to.Hostname: 94e60bba8498 IP: 127.0.0.1 IP: 172.19.0.2 RemoteAddr: 172.19.0.4:43688 GET /admin HTTP/1.1 Host: upstream:8081 User-Agent: curl/8.2.1 Accept: */* Accept-Encoding: gzip Authorization: Bearer eyJhbGciOiJFUzM4NCIsImtpZCI6ImIzNDA3N2ZlNWI5NDczYzBjMmY3NDNmYWQ0MmY3ZDU0YWM3ZTFkN2EiLCJ0eXAiOiJKV1QifQ.eyJleHAiOjE3MTg2OTUwODEsImlhdCI6MTcxODY5NDc4MSwiaXNzIjoiaGVpbWRhbGwiLCJqdGkiOiJiNWRhMDg2OC0yNTFhLTRhZmEtODk4ZS1hZThlYzdkZjMyZDEiLCJuYmYiOjE3MTg2OTQ3ODEsIm9iamVjdHMiOlsiZG9jdW1lbnQ6MTIzNCJdLCJzdWIiOiJhbm5lIn0.GY-4oi75KV8jQz5SgMzVMG_-CcCSi9XpmRE934Uq-A326MBwTcFuHysSYmWNz85wwG5zti2Jijn1T8Vm2fpTVEgEE6qltB9caVQlVNGDyF3uAVdpq9NRgHDcru3-15oB Forwarded: for=172.19.0.1;host=127.0.0.1:9090;proto=http
Try accessing a document with the id
1235
or delete a document using theDELETE
HTTP verb. Useless :). Heimdall won’t let you through. But you can add new relations as you did in Update Relationship Tuples to allowanne
accessing further documents, or delete, or modify existing documents. Try that.
Cleanup
Just stop the environment with CTRL-C
and delete the created files. If you started docker compose in the background, tear the environment down with
$ docker compose down
Last updated on Apr 8, 2025