EACL is an embedded ReBAC authorization library based on SpiceDB, built in Clojure and backed by Datomic. EACL is used at CloudAfrica.
This website is a work-in-progress.
Yes.
I spent the better half of 2024 integrating SpiceDB at CloudAfrica:
[subject relation resource], i.e. some <subject> has <relation> to <resource>, e.g. user:joe is the :owner of account-1.The problem with any external authorization system is modelling & synchronization:
[[product1 :account account1], [product2 :account account2] ...] and so on. But as your data set grows, these queries slow down.I followed exactly this process at CA and it was a nightmare. Plus, because Spice is an external system, you need to deal with failures and retries and eventual consistency.
What if we modelled our Relationships directly in Datomic? There would be zero impedance mismatch and we could simply tail the Datomic transactor queue via d/tx-report-queue, listen for :db/add & :db/retract datoms on a Relationship attr and immediately write/delete it to/from SpiceDB – all our syncing problems go away.
Well, once you model your Spice Relationships directly in Datomic, you might as well model the schema too (to validate them).
And if you already have schema & Relationships, why not just query Datomic directly? That way you don’t need to sync at all – all our problems go away.
If you could make such an implementation fast enough, then you:
And that is exactly what EACL is:
deps.edn fileIAuthorization API that closely resembles SpiceDB’s gRPC protocolIn your database, you probably have :db.type/ref attributes like:
:product/account – which account this product belongs.:account/owner – who owns this account:product/category – categories this product belongs so:product/viewers if you want to share certain resources with other users.You can write Datalog queries to query via product -> account -> user, but these will slow down as the dataset grows. As soon as you introduce inheritance or shared viewers, e.g. product -> account -> viewers, you’ll end up writing lots of Datalog queries that need to be maintained.
Syncing permissions to SpiceDB is a diffing problem if your data model does not model Spice Relations & Relationships, e.g. if you have attributes in Datomic
EACL (Enterprise Access ControL) is an embedded authorization library that lives next to your data in Datomic and avoids an external network hop. EACL is suitable for small-to-medium scale, while giving you the option to migrate to SpiceDB in future when you need more scale and consistency semantics.
EACL implements the SpiceDB gRPC API as an idiomatic Clojure protocol (IAuthorization), using Datomic as a backing graph store. So you can add sophisticated authorization to your Clojure project on day one and migrate to SpiceDB later.
Internally, EACL recursively traverses the permission graph using direct index-based calls (via datomic.api/index-range) to efficiently answer CheckPermission, LookupSubjects and LookupResources queries.
The goal for EACL is to provide best-in-class authorization for Clojure & Datomic applications with <10M entities. It is especially suited to Electric Clojure[^3]. EACL has been open-sourced under the AGPL, but we are likely to relicense it under a more permissive licence. EACL is used at CloudAfrica, a regional cloud host based in South Africa.
EACL can answer the following permission questions by querying Datomic:
<subject> have <permission> on <resource>?”<subjects> have <permission> on <resource>?”<resources> does <subject> have <permission> for?”<subject> do?” i.e. permissions.EACL leverages recursive Datomic graph queries and direct index access to support ReBAC authorization situated next to your data.
Embedded AuthZ offers some advantages for typical use-cases:
In a ReBAC system like EACL, Subjects & Resources are related via Relationships.
A Relationship is just a 3-tuple of [subject relation resource], e.g.
[user1 :owner account1] means subject user1 is the :owner of resource account1, and[account1 :account product1] means subject account1 is the :account for resource product1.To create a relationship, define a potential Relation, like so:
1
2
3
4
5
; Account Resource:
(Relation :account :owner :user) ; an :account can have :owner(s)
(Relation :account :viewer :user) ; an :account can have :viewer(s)
; Product Resource:
(Relation :product :account :account) ; a :product has an :account
Given that an <account> has an :owner, and a <product> can have an :account, we can define a schema that grants :edit permission to owners, and :view permissions to viewers:
1
2
3
4
5
6
7
8
9
10
; account { permission admin = owner }
(Permission :account :admin {:relation :owner})
; account { permission edit = account-.admin }
(Permission :account :edit {:arrow :account :permission :admin})
; product { permission view = admin + account->viewer }
(Permission :product :view {:arrow :account :permission :admin})
(Permission :product :view {:arrow :account :relation :viewer})
; (multiple permissions mean 'OR'. EACL does not support negation.)
The IAuthorization protocol in src/eacl/core.clj defines an idiomatic Clojure interface that maps to and extends the SpiceDB gRPC API:
(eacl/can? client subject permission resource) => true | false(eacl/lookup-subjects client filters) => {:data [subjects...], cursor 'next-cursor}(eacl/lookup-resources client filters) => {:data [resources...], :cursor 'next-cursor}.(eacl/count-resources client filters) => {:keys [count limit cursor]} supports limit & cursor for iterated counting. Use sparingly with :limit -1 for all results.(eacl/read-relationships client filters) => [relationships...](eacl/write-relationships! client updates) => {:zed/token 'db-basis},
updates is just a coll of [operation relationship] where operation is one of :create, :touch or :delete.(eacl/create-relationships! client relationships) simply calls write-relationships! with :create operation.(eacl/delete-relationships! client relationships) simply calls write-relationships! with :delete operation.(eacl/write-schema! client) is not impl. yet because schema lives in Datomic. TODO.(eacl/read-schema client) is not impl. yet because schema lives in Datomic. TODO.(eacl/expand-permission-tree client filters) is not impl. yet.The primary API call is can?, e.g.
1
2
(eacl/can? client subject permission resource)
=> true | false
The other primary API call is lookup-resources, e.g.
1
2
3
4
5
6
7
8
9
10
11
(def page1
(eacl/lookup-resources client
{:subject (->user "alice")
:permission :view
:resource/type :server
:limit 2 ; defaults to 1000.
:cursor nil})) ; pass nil for 1st page.
page1
=> {:cursor 'next-cursor
:data [{:type :server :id "server-1"}
{:type :server :id "server-2"}]}
To query the next page, simply pass the cursor from page1 into the next query:
1
2
3
4
5
6
7
8
9
10
(eacl/lookup-resources client
{:subject (->user "alice")
:permission :view
:resource/type :server
:limit 3
:cursor (:cursor page1)}) ; pass nil for 1st page.
=> {:cursor 'next-cursor
:data [{:type :server :id "server-3"}
{:type :server :id "server-4"}
{:type :server :id "server-5"}]}
The return order of resources from lookup-resources is stable and sorted by internal resource ID. Future enhancements may enable a sort key.
The following example is contained in eacl-example.
Add the EACL dependency to your deps.edn file:
1
2
{:deps {cloudafrica/eacl {:git/url "git@github.com:cloudafrica/eacl.git"
:git/sha "3c4d93a58c49a90c018f72ff99aed2b7ed790831"}}}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
(ns my-eacl-project
(:require [datomic.api :as d]
[eacl.core :as eacl :refer [->Relationship spice-object]]
[eacl.datomic.core]
[eacl.datomic.schema :as schema]
[eacl.datomic.impl :refer [Relation Permission]]))
; Create an in-memory Datomic database:
(def datomic-uri "datomic:mem://eacl")
(d/create-database datomic-uri)
; Connect to it:
(def conn (d/connect datomic-uri))
; Install the latest EACL Datomic Schema:
@(d/transact conn schema/v6-schema)
; Transact your permission schema (details below).
@(d/transact conn
[; Account:
; account { relation owner: user }
(Relation :account :owner :user)
; account {
; permission admin = owner
; permission update = admin
; }
(Permission :account :admin {:relation :owner})
(Permission :account :update {:permission :admin})
; product { relation account: account }
(Relation :product :account :account)
; product { permission edit = account->admin }
(Permission :product :edit {:arrow :account :permission :admin})])
; Transact some Datomic entities with a unique ID, e.g. `:eacl/id`:
@(d/transact conn
[{:eacl/id "user-1"}
{:eacl/id "user-2"}
{:eacl/id "account-1"}
{:eacl/id "product-1"}
{:eacl/id "product-2"}])
; Make an EACL client that satisfies the `IAuthorization` protocol:
(def acl (eacl.datomic.core/make-client conn
; optional config:
{:object-id->ident (fn [obj-id] [:eacl/id obj-id]) ; optional. to convert external IDs to your unique internal Datomic idents, e.g. :eacl/id can be :your/id, which may be a unique UUID or string.
:entid->object-id (fn [db eid] (:eacl/id (d/entity db eid)))})) ; optional. to internal IDs to your external IDs.
; Define some convenience methods over spice-object:
(def ->user (partial spice-object :user))
(def ->account (partial spice-object :account))
(def ->product (partial spice-object :product))
; Write some Relationships to EACL (you can also transact this with your entities):
(eacl/create-relationships! acl
[(eacl/->Relationship (->user "user-1") :owner (->account "account-1"))
(eacl/->Relationship (->account "account-1") :account (->product "product-1"))])
; Run some Permission Checks with `can?`:
(eacl/can? acl (->user "user-1") :update (->account "account-1"))
; => true
(eacl/can? acl (->user "user-2") :update (->account "account-1"))
; => false
(eacl/can? acl (->user "user-1") :edit (->product "product-1"))
; => true
(eacl/can? acl (->user "user-2") :edit (->product "product-1"))
; => false
; You can enumerate the :product resources a :user subject can :edit via `lookup-resources`:
(eacl/lookup-resources acl
{:subject (->user "user-1")
:permission :edit
:resource/type :product
:limit 1000
:cursor nil})
; => {:data [{:type :product, :id "product-1"}]
; :cursor 'cursor}
EACL models two core concepts: Schema & Relationship.
Relations and Permissions:
Relation defines how a <subject> & <resource> can be related via a Relationship.Permission defines which permissions are granted to a subject via a Relationship.
<subject> and <resource> are related via some relation, e.g.
(->user "alice") is the :owner of (->account "acme"), where
(->user "alice") is the Subject,:owner is the name of the relation,(->account "acme") is the Resource,(->Relationship (->user "alice") :owner (->account "acme")), i.e. (Relationship subject relation resource)type and a unique id, just a map of {:keys [type id]}, e.g. {:type :user, :id "user-1"}, or (->user "user-1") for short.Let’s model the following SpiceDB schema in EACL:
1
2
3
4
5
definition user {}
definition account {
relation owner: user
}
We define two resource types, user & account, where a user subject can be an owner of an account resource.
In EACL we use:
1
(Relation resource-type relation-name subject-type)
e.g.
1
(Relation :account :owner :user)
This means a user subject can have an :owner relation to an account, via a Relationship:
1
(Relationship (->user 123) :owner (->account 456))
A Relationship is just a 3-tuple of [subject relation resource].
Let’s add a direct permission to the schema for account resources:
1
2
3
4
5
6
definition user {}
definition account {
relation owner: user
permission update = owner
}
In EACL, Direct Permissions use (Permission resource-type permission {:relation relation_name),
(Permission :account :update {:relation :owner})<user> who is an :owner of an <account>, will have the update permission for that account.However, at this point, all permissions checks will return false because there are no Relationships defined:
1
2
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> false
Before we can do some permission checks, let’s define a Relationship between a user subject and an account resource:
(->Relationship subject relation resource)(eacl/->Relationship (->user "alice") :owner (->account "acme"))We can create Relationships in EACL via create-relationships! or write-relationships!:
1
(eacl/create-relationships! acl [(eacl/->Relationship (->user "alice") :owner (->account "acme"))])
Now that we have a Relationship betweee a user and an account, we can call eacl/can? to check if a user has the permission to update the ACME account:
1
2
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> true
However, Bob cannot, because he is not an :owner of the ACME account:
1
2
(eacl/can? acl (->user "bob") :update (->account "acme"))
=> false
Let’s model an indirect Arrow Permission:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = owner
}
definition product {
relation account: account
permission edit = account->admin ; (this is an arrow permission)
}
The arrow permission implies that any subject who has the admin permission on the related account for that product, will also have the edit permission for that product.
Given that,
(->user "alice") is the :owner of (->account "acme"), and(->account "acme") is the :account for (->product "SKU-123")
EACL can traverse the permission graph from user->account->product to calculate that Alice can edit product SKU-123, but not for any other products.Here is the equivalent EACL schema (these are just Datomic tx-data):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(require '[eacl.datomic.impl :refer [Relation Permission]])
[; Account:
; definition account {
; relation owner: user
;
; permission admin = owner
; permission update = admin
; }
; account { relation owner: user }
(Relation :account :owner :user)
; account { permission admin = owner }
(Permission :account :admin {:relation :owner})
; account { permission update = owner }
(Permission :account :update {:permission :admin})
; Product with an arrow permission:
; definition product {
; relation account: account
; permission edit = account->admin
; }
(Relation :product :account :account)
(Permission :product :edit {:arrow :account :permission :admin})
]
Now you can use can? to check those arrow permissions:
1
2
3
4
5
(eacl/can? acl (->user "alice") :edit (->product "SKU-123"))
=> true ; if Alice is an :owner of the Account for that Product.
(eacl/can? acl (->user "bob") :edit (->product "SKU-123"))
=> false ; if Bob is not the :owner of the Account for that Product.
Internally, EACL models Relations, Permissions and Relationships as Datomic entities, along with several tuple indices for efficient querying.
We have an implementation for the gRPC API that is not open-sourced at this time.
SpiceDB uses strings for subject & resource IDs, whereas EACL internally uses Datomic entity IDs.
Internal Datomic eids are not guaranteed to be stable after a DB rebuild, so EACL lets you configure how IDs should be coerced from internal to external & vice versa, so e.g. you can configure EACL to return a UUID or string in your database. Note that this attribute should have :db/unique :db.unique/identity set:
1
2
3
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [db eid] (:your/id (d/entity db eid)))
:object-id->ident (fn [obj-id] [:your/id obj-id])}))
The default options are to use :eacl/id, but if you want to use internal Datomic eids (e.g. if you don’t expose anything to the outside world), you can pass the following options:
1
2
3
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [_db eid] eid)
:object-id->ident (fn [obj-id] obj-id)}))
eacl.core/spice-object accepts type, id and optionally subject_relation, and returns a SpiceObject.
Your EACL schema lives in Datomic. The following functions correspond to SpiceDB schema and return Datomic entity maps:
(Relation resource-type relation-name subject-type)(Permission resource-type permission-to-grant spec), where spec has {:keys [arrow relation permission]}.(Relationship user1 relation-name server1) confers permission to subject user1 on server1.Permission supports the following syntax:
(Permission resource-type permission {:relation some_relation}) ; the missing :arrow implies :self.(Permission resource-type permission {:permisison some_permission}) ; the missing :arrow implies :self.(Permission resource-type permission {:arrow source :permission via_permission})(Permission resource-type permission {:arrow source :relation via_relation})Internally everything is an arrow permission, but omitted :arrow means :self (reserved word).
e.g.
1
(Permission :server :admin {:arrow :account :relation :owner})
Which you can read as follows:
1
2
3
4
5
6
7
8
9
10
definition account {
relation owner: user
permission admin = owner
}
definition server {
relation account: account
permission admin = account->admin
}
Given the following SpiceDB schema,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
definition user {}
definition platform {
relation super_admin: user
}
definition account {
relation platform: platform
relation owner: user
permission admin = owner + platform->super_admin
}
definition server {
relation account: account
relation shared_admin: user
permission reboot = account->admin + shared_admin
}
How to model this in EACL?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
(require '[datomic.api :as d])
(require '[eacl.datomic.impl :as impl :refer [Relation Permission Relationship]])
@(d/transact conn
[; definition platform {
; relation super_admin: user
; }
(Relation :platform :super_admin :user)
; definition account {
; relation platform: platform
; relation owner: user
; }
(Relation :account :platform :platform)
(Relation :account :owner :user)
; definition account {
; permission admin = owner + platform->super_admin
; }
(Permission :account :admin {:relation :owner})
(Permission :account :admin {:arrow :platform :relation :super_admin})
; definition server {
; relation account: account
; relation shared_admin: user
;
; permission reboot = account->admin + shared_admin
; }
(Relation :server :account :account)
(Relation :server :shared_admin :user)
(Permission :server :reboot {:arrow :account :permission :admin})
(Permission :server :reboot {:relation :shared_admin})])
Now you can transact relationships:
1
2
3
4
5
6
7
8
9
10
11
12
@(d/transact conn
[{:db/id "platform-tempid"
:eacl/id "my-platform"}
{:db/id "user1-tempid"
:eacl/id "user1"}
{:db/id "account1-tempid"
:eacl/id "account1"}
(Relationship "platform-tempid" :platform "account1-tempid")
(Relationship "user1-tempid" :owner "account1-tempid")])
(I’m using tempids in example because entities are defined in same tx as relationships)
permission arrow = relation->via-permission is validpermission arrow = relation->subrelation->permission is not valid (yet).permission admin = owner + shared_adminpermission admin = owner - shared_member (note the minus). Exclusion types require complex caching to avoid multiple can? queries.Permission for each relation in a sum-type permission. In future this can be shortened.subject.relation is not currently supported. It’s useful for group memberships.expand-permission-tree is not implemented yet.read-schema & write-schema! are not supported yet because schema lives in Datomic, but needs to be added soon to validate schema changes.1
clj -X:test
1
clj -M:test -n eacl.datomic.impl.indexed_test
1
clj -X:test :nses '["my-namespace1" "my-namespace2"]'
Note difference between -M & -X switches.
1
clojure -M:test -v my.namespace/test-name
This open-source work was generously funded by my employer, CloudAfrica, a Clojure shop. We occasionally hire Clojure & Datomic experts.