EACL is a situated ReBAC authorization library based on SpiceDB, built in Clojure and backed by Datomic.
Situated here means that your permission data lives next to your application data in Datomic, which has some benefits:
- Avoids a network hop. To leverage SpiceDB's consistency semantics, you need to hit your DB (or cache) to retrieve the latest stored ZedToken anyway, so you might as well query the DB directly, which is what EACL does.
- One less external dependency to deploy & sync relationships.
- Fully consistent queries β an external authz system necessitates eventual consistency.
EACL is pronounced "EE-kΙl", like "eagle" with a k because it keeps a watchful eye on permissions.
- Best-in-class ReBAC authorization for Clojure/Datomic applications that is fast for 10M permissioned Datomic entities.
- Clean migration path to SpiceDB once you need consistency semantics with a heavily optimized cache.
- Retain compatibility with SpiceDB gRPC API to enable 1-for-1 Relationship syncing by tailing Datomic transactor queue.
Please refer to eacl.dev.
- Authentication or AuthN means, "Who are you?"
- Authorization or AuthZ means "What can
<subject>do?", so AuthZ is all about permissions.
Situated AuthZ offers some advantages for typical use-cases:
- If you want ReBAC authorization without an external system, EACL is your only option.
- Storing permission data directly in Datomic avoids network I/O to an external AuthZ system, reducing latency.
- An accurate ReBAC model syncing Relationships 1-for-1 from Datomic to SpiceDB in real-time without complex diffing, for when you need SpiceDB performance or features.
- Queries are fully consistent. If you need consistency semantics like
at_least_as_fresh, use SpiceDB. - EACL is fast. You may be tempted to roll your own ReBAC system using recursive Datomic child rules, but you will find the eager Datalog engine too slow and unable to handle all the grounding cases. The first version of EACL was implemented with Datalog rules, but it was simply too slow and materialized all intermediate results. Correct bidirectional cursor-pagination is also non-trivial, because parallel paths through the permission graph can yield duplicate resources. EACL does this for you with good performance.
- EACL traverses acyclic ReBAC paths via low-level Datomic
d/index-range,d/seek-datoms&d/rseek-datomscalls. Recursive permission closures use deterministic traversal order with request-local dedupe, avoiding both Datomic recursive Datalog materialization and persisted grant caches. Acyclic lookup results are returned in Datomic eid order; recursive lookup results are returned in traversal order.- I have investigated implementing custom Sort Keys, but they are not currently feasible without adding a lot of storage & write costs.
- EACL is fast, but makesΒ no strong performance claimsΒ at this time. For typical workloads, EACL should be as fast as, or faster than, SpiceDB. EACL is not meant for hyperscalers.
- EACL is internally benchmarked against ~800k permissioned resources with good latency (5-30ms per query). You can scale Datomic Peers horizontally and dedicate peers to EACL as needed.
- The performance goal for EACL is to handle 10M permissioned entities with real-time performance.
- EACL does not support all SpiceDB features. Please refer to the limitations section to decide if EACL is right for you.
- Presently, EACL has no cache because graph traversal is fast enough over Datomic's aggressive datom caching even for ~1M permissioned resources. A cache is planned and once it lands, should bring query latency down to ~1-2ms per API call, even for large pages.
- Acyclic lookup performance should scale roughly with permission graph complexity *
O(logN)forNresources in terminal resource Relationship indices. Recursive lookup pages are deterministic traversal-order pages with request-local dedupe; they avoid materializing the full closure, but late pages replay the traversal prefix instead of seeking directly to a global sort key. Subjects are typically sparse compared to resources, i.e. 1k users will have access to 1M resources βΒ rarely the other way around.
Note that EACL v7.2 page tokens are stable: after the first page, :after and :before continue against the same Datomic basis. If your DB changes while a UI is paging, refresh from the first page to see the newest view. You can pass a stable db basis and shave off a few milliseconds by calling the internals in eacl.datomic.impl.indexed directly β these functions take db as an argument directly instead of conn. If you do this, you will need to coerce internal Datomic eids to/from your desired external IDs yourself.
Warning
EACL is under active development.
I try hard not to introduce breaking changes, but if data structures change, the major version will increment.
v7.2 is the current development version of EACL. It includes the minor breaking pagination API change from :cursor/:limit to :first/:after and :last/:before, and introduces the recursive traversal engine. Recursive lookup pagination uses deterministic traversal order instead of global eid order. Releases are not tagged yet, so pin the Git SHA.
In a ReBAC system like EACL, objects (Subjects & Resources) are related via Relationships.
A Relationship is just a 3-tuple of [subject relation resource], e.g.
[user1 :owner account1]means subjectuser1is the:ownerof resourceaccount1, and[account1 :account product1]means subjectaccount1is the:accountfor resourceproduct1.
EACL models two core concepts to model the permission graph: Schema & Relationship.
- Schema consists of
RelationsandPermissions:Relationdefines how a<subject>&<resource>can be related via aRelationship.Permissiondefines which permissions are granted to a subject via a chain ofRelationshipsbetween subjects & resources.- Permissions can be Direct Permissions or indirect, known as Arrow Permissions. An arrow implies a graph traversal.
- A Relationship defines how a
<subject>and<resource>are related via a named relation, e.g.[(->user alice) :owner (->account "acme")]means that(->user "alice")is the Subject,:owneris the name of theRelation(as defined in the schema)(->account "acme")is the Resource- so this reads as
(->user "alice")is the:ownerof(->account "acme"). - In EACL, this is expressed as
(->Relationship (->user "alice") :owner (->account "acme")), i.e.(Relationship subject relation resource) - Subjects & Resources are just maps of
{:keys [type id]}, e.g.{:type :user, :id "user-1"}, or(->user "user-1")when using a helper function.
To create a Relationship, first define your schema using eacl/write-schema!:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
relation viewer: user
permission admin = owner
}
definition product {
relation account: account
permission edit = account->admin
permission view = account->admin + account->viewer
}")This schema defines:
- An
accountcan haveownerandviewerusers, withadminpermission granted to owners - A
productbelongs to anaccount, witheditpermission for account admins andviewpermission for account admins and viewers
In SpiceDB schema DSL, + means union (OR-logic). EACL does not support negation (-) or intersection (&) yet.
The IAuthorization protocol in src/eacl/core.clj defines an idiomatic Clojure interface that maps to and extends the SpiceDB gRPC API:
(eacl/can? acl subject permission resource) => true | false(eacl/lookup-subjects acl filters) => {:data [subjects...] :page-info {...}}(eacl/lookup-resources acl filters) => {:data [resources...] :page-info {...}}(eacl/count-resources acl filters) => {:keys [count limit]}counts the full result set.
(eacl/read-relationships acl filters) => {:data [relationships...] :page-info {...}}(eacl/write-relationships! acl updates) => {:zed/token 'db-basis},- where
updatesis a collection of[operation relationship], andoperationis one of:create,:touchor:delete.
- where
(eacl/create-relationships! acl relationships)simply callswrite-relationships!with:createoperation.(eacl/delete-relationships! acl relationships)simply callswrite-relationships!with:deleteoperation.
All list APIs use the v7.2 pagination contract:
- Forward: pass
:firstand optionally:after. - Backward: pass
:lastand optionally:before. - Responses include
:page-infowith:start-cursor,:end-cursor,:has-next-page?, and:has-previous-page?. :cursorand:limitare no longer supported for list pagination.- Acyclic lookup cursors paginate in Datomic eid order. Recursive lookup cursors paginate in deterministic traversal order.
(eacl/write-schema! acl schema-string)parses a SpiceDB schema DSL string, validates it, computes deltas against existing schema, checks for orphaned relationships, and transacts changes atomically.(eacl/read-schema acl)returns the current schema as a map of{:relations [...] :permissions [...]}.(eacl/expand-permission-tree acl filters)is not impl. yet. It is a low priority to implement.
The primary API call is can?, e.g.
(eacl/can? acl subject permission resource)
=> true | falseThe other primary API call is lookup-resources, e.g.
(def page1
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 2})) ; defaults to 1000.
page1
=> {:data [{:type :server :id "server-1"}
{:type :server :id "server-2"}]
:page-info {:start-cursor "eacl3_..."
:end-cursor "eacl3_..."
:has-next-page? true
:has-previous-page? false}}To query the next page, pass the :end-cursor from page1 as :after:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 3
:after (get-in page1 [:page-info :end-cursor])})
=> {:data [{:type :server :id "server-3"}
{:type :server :id "server-4"}
{:type :server :id "server-5"}]
:page-info {:start-cursor "eacl3_..."
:end-cursor "eacl3_..."
:has-next-page? true
:has-previous-page? true}}To go back from page2, pass its :start-cursor as :before with :last:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:last 2
:before (get-in page2 [:page-info :start-cursor])})Forward and backward pages return results in the same order for the query. Acyclic lookup uses Datomic eid order; recursive lookup uses deterministic traversal order. Backward pagination returns the previous window; it does not reverse the result order. Bare :last without :before is not supported for recursive lookup because it requires traversing the full closure.
The following example is contained in eacl-example.
Add the EACL dependency to your deps.edn file:
{:deps {theronic/eacl {:git/url "git@github.com:theronic/eacl.git"
:git/sha "f8c3c1cf67646236ca538942120a03edde40fee7"}}}(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]))
; 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/v7-schema)
; Write your permission schema using SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->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.
:entity->object-id (fn [ent] (:eacl/id ent))})) ; optional. to internal entities to your external IDs.
; Define some convenience methods over spice-object:
; `eacl.core/spice-object` is just a record helper that accepts `type`, `id` and optionally `subject_relation`, to return a SpiceObject of {:keys [type id]}. `subject-relation` is not currently supported in EACL.
(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
:first 1000})
; => {:data [{:type :product, :id "product-1"}]
; :page-info {:start-cursor "eacl3_..."
; :end-cursor "eacl3_..."
; :has-next-page? false
; :has-previous-page? false}}EACL uses the SpiceDB schema DSL to define your authorization model. Use eacl/write-schema! to parse, validate, and transact your schema:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")write-schema! validates your schema and provides informative error messages:
- Reference validation: Ensures all relations and permissions reference valid definitions
- Orphan protection: Prevents deleting relations that have existing relationships
- Unsupported feature detection: Rejects SpiceDB features not yet supported by EACL (see Limitations)
When you call write-schema! with a modified schema, EACL:
- Parses the new schema
- Computes deltas (additions/retractions) against existing schema
- Validates retractions won't orphan existing relationships
- Transacts changes atomically
Let's model the following SpiceDB schema in EACL:
definition user {}
definition account {
relation owner: user
}
We define two resource types, user & account, where any user subject can be the :owner of an account resource.
A Relationship is just a 3-tuple of [subject relation resource]:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))Let's add a direct permission to the schema for account resources:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission update = owner
}")Here, permission update = owner means any user who is an :owner of an account will have the update permission for that account.
At this point, all permissions checks via eacl/can? will return false, because there are no Relationships defined:
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> falseWhat happens when we create some Relationships between users & accounts?
In EACL, Relationships are expressed as 3-tuples of [subject relation resource] using the ->Relationship helper, e.g. user alice is an :owner of acme account:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))Now let's create a Relationship between a user subject and an account resource using eacl/create-relationships!:
(eacl/create-relationships! acl [(eacl/->Relationship (->user "alice") :owner (->account "acme"))])Note: eacl/create-relationships! is just a wrapper over eacl/write-relationships! with the :create operation. It will throw if there is an existing relationship that matches input.
Now that we have created a Relationship between a user and an account, we call eacl/can? to check if a user has the :update permission on the ACME account, e.g. "can Alice :update the ACME account?"
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> trueIndeed, she can. Why? Because Alice is an :owner of the ACME account and the :update permission is granted to all users who are :owner(s).
Can Bob :update the ACME account?
(eacl/can? acl (->user "bob") :update (->account "acme"))
=> falseNo, he cannot, because Bob is not an :owner of the ACME account.
Arrow permissions imply a graph hop. Arrows are designated by -> in the SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")Here, permission edit = account->admin states that subjects are granted the edit permission if, and only if they have the admin permission on the related account for that product. Only account owners have the admin permission on the related account. So given that:
(->user "alice")is the:ownerof(->account "acme"), and(->account "acme")is the:accountfor(->product "SKU-123"),- EACL can traverse the permission graph from user -> account -> product to derive that Alice has the
:editpermission on productSKU-123.
Now you can use can? to check those arrow permissions:
(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.
SpiceDB uses strings for all external subject & resource IDs, whereas EACL uses Datomic entity IDs internally for all IDs. However, EACL lets you configure how internal IDs should be coerced to external IDs and vice versa.
Note: internal Datomic eids should not be exposed to consumers, because those eids are not guaranteed to be stable after a DB rebuild.
eacl.datomic.core/make-client accepts a Datomic conn and a config map of {:keys [entity->object-id object-id->ident]}, which are functions to convert between internal to/from external IDs.
It is common to attach a unique UUID to permissioned entities for exposing them externally, or you can convert external->internal at your call sites. Here is how you can configure EACL to convert to/from a unique attribute named :your/id:
(def acl (eacl.datomic.core/make-client conn
{:entity->object-id (fn [ent] (:your/id ent))
:object-id->ident (fn [obj-id] [:your/id obj-id])}))Note that this attribute should have property :db/unique :db.unique/identity.
The default options are to use the built-in EACL string attr :eacl/id, but you can use the internal Datomic eids with the following "identity" functions:
(def acl (eacl.datomic.core/make-client conn
{:entity->object-id (fn [ent] (:db/id ent))
:object-id->ident (fn [obj-id] obj-id)}))For multi-peer deployments, configure a shared page-token key so eacl3_... cursors survive restarts and can be decoded by every peer:
(def acl (eacl.datomic.core/make-client conn
{:page-token-key "32+ bytes of shared secret key material"
:page-token-kid :prod-2026-05}))EACL uses the SpiceDB schema DSL. Use eacl/write-schema! to define your schema:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}
definition server {
relation account: account
permission admin = account->admin
}")For advanced use cases, you can also define schema programmatically using the internal Relation and Permission functions:
(require '[eacl.datomic.impl :refer [Relation Permission]])
@(d/transact conn
[(Relation :account :owner :user)
(Permission :account :admin {:relation :owner})
(Relation :server :account :account)
(Permission :server :admin {:arrow :account :permission :admin})])Permission supports the following spec syntax:
{:relation some_relation}- direct permission via relation{:permission some_permission}- permission via another permission{:arrow source :permission via_permission}- arrow to permission{:arrow source :relation via_relation}- arrow to relation
Here's a complete example of defining a schema with eacl/write-schema!:
(eacl/write-schema! acl
"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
}")This schema defines:
platformresources can havesuper_adminusersaccountresources can have aplatformandowner, withadminpermission granted to owners and platform super_adminsserverresources belong to anaccountand can haveshared_adminusers, withrebootpermission granted to account admins and shared_admins
Now you can transact relationships:
```clojure
@(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)
- No consistency semantics: all EACL queries are
fully_consistent.- If you need consistency semantics, use SpiceDB. SpiceDB is heavily optimised to maintain a consistent cache across the graph.
- No negation operator: EACL only supports Union (
+) permission operators, not-negation, e.g.permission admin = owner + shared_adminis valid,- but
permission admin = owner - banned_memberis not (note the-Negation operator). - You can work around this limitation by doing a negation in your application logic, e.g.
(and (not (eacl/can? acl ...) (eacl/can? acl ...))), but it's not free. Once EACL has a cache, this becomes more viable to implement in EACL.
- Arrow syntax is limited to one level of nesting, e.g.
permission arrow = relation->via-permissionis supported,- but
permission arrow = relation->subrelation->permissionis not. To implement this would require anonymous shadow relations. May require schema changes.
- You need to specify a
Permissionfor each relation in a sum-type permission. In future this can be shortened. subject.relationis not currently supported. It's useful for group memberships.expand-permission-treeis not implemented yet.- No cache: EACL does not presently have a cache, because Datomic Peers cache datoms aggressively and queries so far are fast enough. A cache is planned.
- Return order: Acyclic EACL lookups enumerate in Datomic eid order and relationship reads enumerate in tuple-index order. Recursive lookups enumerate in deterministic traversal order. SpiceDB returns results in discovery or schema order. You should not rely on either system's order as a domain sort order.
clj -X:testclj -M:test -n eacl.datomic.impl.indexed_testclj -X:test :nses '["my-namespace1" "my-namespace2"]'Note difference between -M & -X switches.
clojure -M:test -v my.namespace/test-nameSome of this open-source work was generously funded by my former employer, CloudAfrica.
- EACL is licensed under the Eclipse Public License v2.0 since 2026-03-05.
- EACL was initially licensed under BSL and later Affero GPL (on 2025-05-27), but is now licensed under EPL 2.0.