This is a demo of integrating Authorino with Authzed's SpiceDB.
SpiceDB is a Google Zanzibar-inspired authorization system that, like Google Zanzibar, allows for the modeling of fine-grained permissions based on relationships (Relationship-Based Access Control, or ReBAC).
One of the main challenges of implementing fine-grained permissions with an external authorization system is making that system aware of the existing relations. In this demo, we use Authorino callbacks to inform SpiceDB about the permissions implied by the operations requested by the users, such as creating or deleting an application resource, as well as granting and revoking access to resources for third-party users.
The full scope of the demo consists of protecting endpoints of a REST API that handles documents, the Docs API. Any authenticated user with a valid API key is allowed to create documents. Users can read and delete their own documents, as well as grant read access to their documents for other users. All fine-grained permissions involved are automatically stored in SpiceDB by Authorino, based on the operations requested by the users to the Docs API.
- Kubernetes cluster
Started locally with Kind. - Docs API
A REST API application that will be protected using SpiceDB and Authorino.
The following HTTP endpoints are available:GET /docs List all docs GET /docs/{id} Read a doc POST /docs/{id} Create a doc DELETE /docs/{id} Delete a doc
- Envoy proxy
Deployed as sidecar of the Docs API, to serve the application with the External Authorization filter enabled and pointing to Authorino. After deploying the sidecar, the following additional endpoints are introduced:POST /docs/{id}/allow/{user} Grant read access to the doc DELETE /docs/{id}/allow/{user} Revoke read access to the doc
- Authorino Operator
Cluster-wide installation of the operator and CRDs to manage and use Authorino authorization services. - Authorino
The external authorization service, deployed innamespaced
reconciliation mode, in the same K8s namespace as the Docs API. - SpiceDB
Open source Zanzibar-inspired database to store, compute, and validate fine grained permissions. - Contour
Kubernetes Ingress Controller based on the Envoy proxy, to handle the ingress traffic to the Docs API and to Keycloak.
Note: For simplicity, in the demo all components are deployed without TLS.
🅰 Create the cluster
kind create cluster --name authorino-demo --config -<<EOF
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 80
hostPort: 80
listenAddress: "0.0.0.0"
- containerPort: 443
hostPort: 443
listenAddress: "0.0.0.0"
EOF
🅱 Install Contour
kubectl apply -f https://raw.githubusercontent.com/guicassolato/authorino-spicedb/main/contour.yaml
🅲 Install the Authorino Operator
kubectl apply -f https://raw.githubusercontent.com/Kuadrant/authorino-operator/main/config/deploy/manifests.yaml
Note: In OpenShift, the Authorino Operator can alternatively be installed directly from the Red Hat OperatorHub, using Operator Lifecycle Manager.
🅰 Create the namespace
kubectl create namespace docs-api
🅱 Deploy the Docs API in the namespace
kubectl -n docs-api apply -f -<<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs-api
labels:
app: docs-api
spec:
selector:
matchLabels:
app: docs-api
template:
metadata:
labels:
app: docs-api
spec:
containers:
- name: docs-api
image: quay.io/kuadrant/authorino-examples:docs-api
imagePullPolicy: IfNotPresent
env:
- name: PORT
value: "3000"
tty: true
ports:
- containerPort: 3000
replicas: 1
---
apiVersion: v1
kind: Service
metadata:
name: docs-api
labels:
app: docs-api
spec:
selector:
app: docs-api
ports:
- name: http
port: 3000
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docs-api
labels:
app: docs-api
spec:
rules:
- host: docs-api.127.0.0.1.nip.io
http:
paths:
- backend:
service:
name: docs-api
port:
number: 3000
path: /docs
pathType: Prefix
EOF
🅲 Try the Docs API unprotected
curl http://docs-api.127.0.0.1.nip.io/docs -i
# HTTP/1.1 200 OK
🅰 Create the namespace
kubectl create namespace spicedb
🅱 Deploy the SpiceDB instance
kubectl -n spicedb apply -f -<<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: spicedb
labels:
app: spicedb
spec:
selector:
matchLabels:
app: spicedb
template:
metadata:
labels:
app: spicedb
spec:
containers:
- name: spicedb
image: authzed/spicedb
args:
- serve
- "--grpc-preshared-key"
- secret
- "--http-enabled"
ports:
- containerPort: 50051
- containerPort: 8443
replicas: 1
---
apiVersion: v1
kind: Service
metadata:
name: spicedb
spec:
selector:
app: spicedb
ports:
- name: grpc
port: 50051
protocol: TCP
- name: http
port: 8443
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: spicedb
labels:
app: spicedb
spec:
rules:
- host: spicedb.127.0.0.1.nip.io
http:
paths:
- backend:
service:
name: spicedb
port:
number: 8443
path: /
pathType: Prefix
EOF
🅲 Create the permission schema
curl -X POST http://spicedb.127.0.0.1.nip.io/v1/schema/write \
-H 'Authorization: Bearer secret' \
-H 'Content-Type: application/json' \
-d @- <<EOF
{
"schema": "definition user {}\ndefinition doc {\n\trelation reader: user\n\trelation writer: user\n\n\tpermission read = reader + writer\n\tpermission write = writer\n}"
}
EOF
🅰 Request an instance of Authorino
kubectl -n docs-api apply -f -<<EOF
apiVersion: operator.authorino.kuadrant.io/v1beta1
kind: Authorino
metadata:
name: authorino
spec:
listener:
tls:
enabled: false
oidcServer:
tls:
enabled: false
EOF
🅱 Redeploy the Docs API with the sidecar proxy
kubectl -n docs-api apply -f -<<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs-api
labels:
app: docs-api
spec:
selector:
matchLabels:
app: docs-api
template:
metadata:
labels:
app: docs-api
spec:
containers:
- name: docs-api
image: quay.io/kuadrant/authorino-examples:docs-api
imagePullPolicy: IfNotPresent
env:
- name: PORT
value: "3000"
tty: true
ports:
- containerPort: 3000
- name: envoy
image: envoyproxy/envoy:v1.19-latest
imagePullPolicy: IfNotPresent
command:
- /usr/local/bin/envoy
args:
- --config-path /usr/local/etc/envoy/envoy.yaml
- --service-cluster front-proxy
- --log-level info
- --component-log-level filter:trace,http:debug,router:debug
ports:
- containerPort: 8000
volumeMounts:
- mountPath: /usr/local/etc/envoy
name: config
readOnly: true
volumes:
- name: config
configMap:
items:
- key: envoy.yaml
path: envoy.yaml
name: envoy
replicas: 1
---
apiVersion: v1
kind: Service
metadata:
name: docs-api
labels:
app: docs-api
spec:
selector:
app: docs-api
ports:
- name: envoy
port: 8000
protocol: TCP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docs-api
labels:
app: docs-api
spec:
rules:
- host: docs-api.127.0.0.1.nip.io
http:
paths:
- backend:
service:
name: docs-api
port:
number: 8000
path: /docs
pathType: Prefix
---
apiVersion: v1
kind: ConfigMap
metadata:
name: envoy
labels:
app: envoy
data:
envoy.yaml: |
static_resources:
clusters:
- name: docs-api
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: docs-api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 3000
- name: authorino
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
http2_protocol_options: {}
load_assignment:
cluster_name: authorino
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: authorino-authorino-authorization
port_value: 50051
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: local
route_config:
name: docs-api
virtual_hosts:
- name: docs-api
domains: ['*']
routes:
- match:
prefix: /
route:
cluster: docs-api
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
failure_mode_allow: false
include_peer_certificate: true
grpc_service:
envoy_grpc:
cluster_name: authorino
timeout: 1s
- name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
function envoy_on_request(request_handle)
if string.match(request_handle:headers():get(":path"), '^/docs/[^/]+/allow/.+') then
request_handle:respond({[":status"] = "200"}, "")
end
end
- name: envoy.filters.http.router
typed_config: {}
use_remote_address: true
admin:
access_log_path: "/tmp/admin_access.log"
address:
socket_address:
address: 0.0.0.0
port_value: 8001
EOF
🅲 Try the Docs API without authentication
curl http://docs-api.127.0.0.1.nip.io/docs -i
# HTTP/1.1 404 Not Found
# x-ext-auth-reason: Service not found
# server: envoy
# ...
🅰 Create the AuthConfig
kubectl -n docs-api apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta1
kind: AuthConfig
metadata:
name: docs-api-protection
spec:
hosts:
- docs-api.127.0.0.1.nip.io
patterns:
create:
- selector: context.request.http.method
operator: eq
value: POST
- selector: context.request.http.path.@extract:{"sep":"/","pos":3}
operator: neq
value: allow
list:
- selector: context.request.http.method
operator: eq
value: GET
- selector: context.request.http.path.@extract:{"sep":"/","pos":2}
operator: eq
value: ""
# Users authenticated with API keys
identity:
- name: api-key-users
apiKey:
selector:
matchLabels:
app: docs-api
credentials:
in: authorization_header
keySelector: APIKEY
metadata:
# List resources → lookup resources the user has read access to
- name: permission-lookup-read
when:
- patternRef: list
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/permissions/resources
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"resourceObjectType":"doc",
"permission":"read",
"subject":\{
"object":\{
"objectType":"user",
"objectId":"{auth.identity.metadata.annotations.username}"
\}
\}
\}
sharedSecretRef:
name: spicedb
key: token
# Create resource → lookup resources the user has write access to
- name: permission-lookup-write
when:
- patternRef: create
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/permissions/subjects
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"resource": \{
"objectType": "doc",
"objectId": "{context.request.http.path.@extract:{"sep":"/","pos":2}}"
\},
"permission": "write",
"subjectObjectType": "user"
\}
sharedSecretRef:
name: spicedb
key: token
authorization:
# Read or delete a resource → check in SpiceDB if the user has read or write permission respectively
- name: read-or-delete-resource
when:
- selector: context.request.http.method
operator: neq
value: POST
- selector: context.request.http.path.@extract:{"sep":"/","pos":2}
operator: neq
value: ""
- selector: context.request.http.path.@extract:{"sep":"/","pos":3}
operator: neq
value: allow
authzed:
endpoint: spicedb.spicedb.svc.cluster.local:50051
insecure: true
sharedSecretRef:
name: spicedb
key: token
subject:
kind:
value: user
name:
valueFrom:
authJSON: auth.identity.metadata.annotations.username
resource:
kind:
value: doc
name:
valueFrom:
authJSON: context.request.http.path.@extract:{"sep":"/","pos":2}
permission:
valueFrom:
authJSON: context.request.http.method.@replace:{"old":"GET","new":"read"}.@replace:{"old":"DELETE","new":"write"}
# Create a resource → ensure the writer relationship does not exist in SpiceDB
- name: create-resource
when:
- patternRef: create
json:
rules:
- selector: auth.metadata.permission-lookup-write.result
operator: eq
value: ""
# Grant or revoke access to resource → check in SpiceDB if the user has write permission
- name: grant-or-revoke-access-to-resource
when:
- selector: context.request.http.path.@extract:{"sep":"/","pos":3}
operator: eq
value: allow
authzed:
endpoint: spicedb.spicedb.svc.cluster.local:50051
insecure: true
sharedSecretRef:
name: spicedb
key: token
subject:
kind:
value: user
name:
valueFrom:
authJSON: auth.identity.metadata.annotations.username
resource:
kind:
value: doc
name:
valueFrom:
authJSON: context.request.http.path.@extract:{"sep":"/","pos":2}
permission:
value: write
response:
# Create new resource → inject user info in the request
- name: x-ext-auth-data
when:
- patternRef: create
json:
properties:
- name: author
valueFrom: { authJSON: auth.identity.metadata.annotations.fullname }
- name: user_id
valueFrom: { authJSON: auth.identity.metadata.annotations.username }
# List resources → filter resource ids the user has access to
- name: x-filter
when:
- patternRef: list
json:
properties:
- name: id
valueFrom:
authJSON: auth.metadata.permission-lookup-read.result.resourceObjectId
- name: ids
valueFrom:
authJSON: auth.metadata.permission-lookup-read.#.result.resourceObjectId
callbacks:
# Create new resource → create 'writer' relationship in SpiceDB
- name: create-resource
when:
- selector: auth.authorization.create-resource
operator: neq
value: ""
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/relationships/write
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"updates":[
\{
"operation":"OPERATION_CREATE",
"relationship":\{
"resource":\{
"objectType":"doc",
"objectId":"{context.request.http.path.@extract:{"sep":"/","pos":2}}"
\},
"relation":"writer",
"subject":\{
"object":\{
"objectType":"user",
"objectId":"{auth.identity.metadata.annotations.username}"
\}
\}
\}
\}
]
\}
sharedSecretRef:
name: spicedb
key: token
# Delete resource → delete all corresponding relationships in SpiceDB
- name: delete-resource
when:
- selector: auth.authorization.read-or-delete-resource
operator: neq
value: ""
- selector: context.request.http.method
operator: eq
value: DELETE
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/relationships/delete
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"relationshipFilter": \{
"resourceType": "doc",
"optionalResourceId": "{context.request.http.path.@extract:{"sep":"/","pos":2}}"
\}
\}
sharedSecretRef:
name: spicedb
key: token
# Grant access to resource → create 'reader' relationship in SpiceDB
- name: grant-access
when:
- selector: auth.authorization.grant-or-revoke-access-to-resource
operator: neq
value: ""
- selector: context.request.http.method
operator: eq
value: POST
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/relationships/write
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"updates":[
\{
"operation":"OPERATION_CREATE",
"relationship":\{
"resource":\{
"objectType":"doc",
"objectId":"{context.request.http.path.@extract:{"sep":"/","pos":2}}"
\},
"relation":"reader",
"subject":\{
"object":\{
"objectType":"user",
"objectId":"{context.request.http.path.@extract:{"sep":"/","pos":4}}"
\}
\}
\}
\}
]
\}
sharedSecretRef:
name: spicedb
key: token
# Revoke access to resource → delete 'reader' relationships in SpiceDB
- name: revoke-access
when:
- selector: auth.authorization.grant-or-revoke-access-to-resource
operator: neq
value: ""
- selector: context.request.http.method
operator: eq
value: DELETE
http:
endpoint: http://spicedb.spicedb.svc.cluster.local:8443/v1/relationships/delete
method: POST
contentType: application/json
body:
valueFrom:
authJSON: |
\{
"relationshipFilter": \{
"resourceType": "doc",
"optionalResourceId": "{context.request.http.path.@extract:{"sep":"/","pos":2}}",
"optionalRelation": "reader",
"optionalSubjectFilter": \{
"subjectType": "user",
"optionalSubjectId": "{context.request.http.path.@extract:{"sep":"/","pos":4}}"
\}
\}
\}
sharedSecretRef:
name: spicedb
key: token
---
apiVersion: v1
kind: Secret
metadata:
name: spicedb
labels:
app: spicedb
stringData:
token: secret
EOF
🅱 Create the API keys for users to consume the Docs API
kubectl -n docs-api apply -f -<<EOF
apiVersion: v1
kind: Secret
metadata:
name: api-key-writer
labels:
authorino.kuadrant.io/managed-by: authorino
app: docs-api
annotations:
username: emilia
fullname: 👩🏾 Emilia Jones
stringData:
api_key: IAMEMILIA
---
apiVersion: v1
kind: Secret
metadata:
name: api-key-reader
labels:
authorino.kuadrant.io/managed-by: authorino
app: docs-api
annotations:
username: beatrice
fullname: 🧑🏻🦰 Beatrice Smith
stringData:
api_key: IAMBEATRICE
EOF
🅲 Consume the Docs API fully protected
As 👩🏾 Emilia, create a doc:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X POST \
-H 'Content-Type: application/json' \
-d '{"title":"Emilia´s doc","body":"This is Emilia´s doc."}' \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 200 OK
# ...
# {"id":"123","title":"Emilia´s doc","body":"This is Emilia´s doc.","date":"2023-02-07 18:17:30 +0000","author":"👩🏾 Emilia Jones","user_id":"emilia"}
As 👩🏾 Emilia, read the doc just created:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X GET \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 200 OK
As 🧑🏻🦰 Beatrice, try to read the doc created by Emilia:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
-X GET \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 403 Forbidden
# x-ext-auth-reason: PERMISSIONSHIP_NO_PERMISSION;token=...
As 👩🏾 Emilia, grant access to the doc for 🧑🏻🦰 Beatrice:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X POST \
http://docs-api.127.0.0.1.nip.io/docs/123/allow/beatrice -i
# HTTP/1.1 200 OK
As 🧑🏻🦰 Beatrice, try again to read the doc owned by Emilia:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
-X GET \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 200 OK
As 🧑🏻🦰 Beatrice, create a doc of her own:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
-X POST \
-H 'Content-Type: application/json' \
-d '{"title":"Beatrice´s doc","body":"This is Beatrice´s doc."}' \
http://docs-api.127.0.0.1.nip.io/docs/456 -i
# HTTP/1.1 200 OK
# ...
# {"id":"456","title":"Beatrice´s doc","body":"This is Beatrice´s doc.","date":"2023-02-07 18:25:10 +0000","author":"🧑🏻🦰 Beatrice Smith","user_id":"beatrice"}
As 🧑🏻🦰 Beatrice, list all the docs Beatrice has access to:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
http://docs-api.127.0.0.1.nip.io/docs -i
# HTTP/1.1 200 OK
# ...
# [
# {"id":"123","title":"Emilia´s doc","body":"This is Emilia´s doc.","date":"2023-02-07 18:17:30 +0000","author":"👩🏾 Emilia Jones","user_id":"emilia"},
# {"id":"456","title":"Beatrice´s doc","body":"This is Beatrice´s doc.","date":"2023-02-07 18:25:10 +0000","author":"🧑🏻🦰 Beatrice Smith","user_id":"beatrice"}
# ]
As 👩🏾 Emilia, list all the docs Emilia has access to:
curl -H 'Authorization: APIKEY IAMEMILIA' \
http://docs-api.127.0.0.1.nip.io/docs -i
# HTTP/1.1 200 OK
# ...
# [{"id":"123","title":"Emilia´s doc","body":"This is Emilia´s doc.","date":"2023-02-07 18:17:30 +0000","author":"👩🏾 Emilia Jones","user_id":"emilia"}]
As 👩🏾 Emilia, revoke 🧑🏻🦰 Beatrice's access to the doc:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X DELETE \
http://docs-api.127.0.0.1.nip.io/docs/123/allow/beatrice -i
# HTTP/1.1 200 OK
As 🧑🏻🦰 Beatrice, list again the docs Beatrice has access to:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
http://docs-api.127.0.0.1.nip.io/docs -i
# HTTP/1.1 200 OK
# ...
# [{"id":"456","title":"Beatrice´s doc","body":"This is Beatrice´s doc.","date":"2023-02-07 18:25:10 +0000","author":"🧑🏻🦰 Beatrice Smith","user_id":"beatrice"}]
As 🧑🏻🦰 Beatrice, try one last time to read the doc owned by Emilia:
curl -H 'Authorization: APIKEY IAMBEATRICE' \
-X GET \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 403 Forbidden
# x-ext-auth-reason: PERMISSIONSHIP_NO_PERMISSION;token=...
As 👩🏾 Emilia, delete the doc:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X DELETE \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 200 OK
As 👩🏾 Emilia, retry to read the doc just deleted:
curl -H 'Authorization: APIKEY IAMEMILIA' \
-X GET \
http://docs-api.127.0.0.1.nip.io/docs/123 -i
# HTTP/1.1 403 Forbidden
# x-ext-auth-reason: PERMISSIONSHIP_NO_PERMISSION;token=...
kind delete cluster --name authorino-demo
Because Authorino builds in SpiceDB the permission relationships implied by the requests sent to the Docs API before these requests effectively hit the application, and at the same time the application itself has no knowledge of the authorization system in place at all, the system may run into a situation where the resources and relations stored in the application mismatch the state of the relationships in SpiceDB. This can happen, for example, if an authorized request (i.e. after passing Authorino) fails to be processed by the application due to a server error.
To mitigate the risk of consistency issues, the HTTP requests sent to the Docs API must be treated as an atomic transaction from the moment Envoy hands it over to Authorino, until the upstream application response is processed by Envoy.
To be able to recover from possible consistency issues in case the mitigation fails, logs of the requests handled by Authorino can be stored including timestamp, username, as well as method and path of the HTTP request. Such logs can be implemented by adding another Authorino callback in the AuthConfig. The system should occasionally check for consistency issues and use the logs to rebuild the desired state incrementally.
Compared to monolithic approach of embedded authorization rules and without proxy in the middle, another caveat of this implementation comes from the extra hops involved in the communication between sidecar proxy (Envoy) and authorization service (Authorino), authorization service and permission database/policy engine (SpiceDB), and sidecar proxy and upstream application (Docs API), and its significance in terms of added latency to the overall request.
To mitigate the impact on latency especially due to the HTTP and GRPC communication between Authorino and SpiceDB, caching can be enabled in Authorino for the metadata
and authorization
rules.
Performance can also be improved once callbacks
in the AuthConfig can be processed asynchronously (see Kuadrant/authorino#369).