can? one decision
(eacl/can? acl (->user "sysadmin") :enter (->area "server-room"))(eacl/can? acl (->user "boss") :enter (->area "server-room"))(eacl/can? acl (->user "intern") :operate (->asset "power"))🦅 EACL: Enterprise Access ControL is a situated ReBAC Authorization library inspired by SpiceDB, built in Clojure and backed by Datomic Pro, Datahike, Datalevin or DataScript.
;; loading EACL into the page…
It can also enumerate access: who can access what and what can a subject access.
;; the rules EACL HQ's bouncers and assets ask EACL, in the SpiceDB schema language EACL reads
caveat on_days(today string, days list<string>) { today in days}
caveat nothing_sensitive(inventory list<string>, sensitive list<string>) { !inventory.exists(item, item in sensitive)}
definition user {}
definition team { relation member: user}
definition building { relation employee: user// a pass that holds on some days alone relation contractor: user | user with on_days relation suspended: user
// suspended beats any badge or pass permission enter = (employee + contractor) - suspended}
definition area { relation building: building relation team: team// anyone may leave, carrying nothing sensitive relation anyone: user:* with nothing_sensitive
permission enter = building->enter & team->member permission exit = anyone}
definition asset { relation area: area relation team: team relation operator: user relation admin: user relation barred: user
// its team or its operators, in its area, but not the barred permission operate = (team->member + operator) & area->enter - barred permission unplug = admin & operate}“EACL is concerned with fast & correct authorization, i.e. permissions.” — README
A
— README, ReBACRelationshipis just a 3-tuple of[subject relation resource]
;; EACL HQ's, as EACL holds them, each an edge from its subject to its resource; the dotted ones carry a Caveat. A door opens, or an asset works, when the edges connect a person to it the way the schema says. The lit path is the last decision's, traced by asking EACL at every step; a refusal shows what was missing, expired, excluding, or whose Caveat did not hold. Click anything here to select it and light its own paths.
(defn reset-hq!
"EACL HQ as it opens: a new DataScript database with EACL's schema, the building's entities, its
rules and facts, and a new EACL client on the building's clock; and its list of what is sensitive.
The schema and every relationship go to EACL in two calls, noted for the page's authorization logs."
[]
(let [conn (eacl.datascript/create-conn {:app/id {:db/unique :db.unique/identity}})
client (eacl.datascript/make-client
conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (ds/entity db eid)))
:clock (fn [] @!now)
;; a public demo's key for EACL's cursors, as EACL Drive's is
:security-key "petrustheron.com-eacl-hq-demo-key-2026"})]
(ds/transact! conn (mapv (fn [x] {:app/id (:id x)}) (concat (map #(o :user %) people) places)))
(journal! "write-schema!" [{:eacl/handle "acl"} schema] #(eacl/write-schema! client schema))
(let [updates (mapv (fn [r] {:operation :touch :relationship r})
(concat
(map (fn [[s r x]] (eacl/->Relationship s r x)) relationships)
;; the intern's badge lasts a week: an expiring relationship
[(assoc (eacl/->Relationship (o :user "intern") :employee (o :building "hq")) :valid-until-ms (+ epoch (* 7 day-ms)))
;; the janitor's pass holds on Tuesdays and Thursdays: a Caveat, which the day at the door decides
(assoc (eacl/->Relationship (o :user "janitor") :contractor (o :building "hq"))
:caveat "on_days" :caveat-context {"days" ["tuesday" "thursday"]})]
;; anyone may leave any area, unless they carry something sensitive: user:*, SpiceDB's wildcard, one to an area
(for [a areas]
(assoc (eacl/->Relationship (o :user "*") :anyone (o :area a)) :caveat "nothing_sensitive"))))]
(journal! "write-relationships!" [{:eacl/handle "acl"} updates] #(eacl/write-relationships! client updates)))
(reset! !system {:conn conn :acl client})
(reset! !sensitive items)
(swap! !generation inc)
;; every transaction, from the level, the REPL or a button, tells the page
(ds/listen! conn ::page (fn [_] (tell-page!)))
(tell-page!)
:reset))(def relationships
"EACL HQ's facts as it opens. The boss may go anywhere but the server room; the programmer and the
sysadmin have the server room; the bean counter and the boss keep the vault; everyone on the
staff has the office. The assets: the computer, for ops and the boss; the mainframe in the server
room, for ops, and its servers, for their one operator, the sysadmin, and no team; the treasury in
the vault; the power, for the staff but the intern; the coffee machine, for the staff; and the
vault's bar fridge, for finance but the boss, and its operators the programmer and the boss's
nephew (owner, 2026-09-28: \"grant the programmer & nephew access to the bar fridge\"), who may
still not open it: the programmer may not enter the vault, and the nephew is suspended. The
jukebox is the staff's but the intern's. The boss's nephew is suspended, with their old
relationships still standing. The intern's badge lasts a week, and the janitor's pass holds on
Tuesdays and Thursdays: reset-hq! writes both."
[[(o :user "boss") :employee (o :building "hq")]
[(o :user "programmer") :employee (o :building "hq")]
[(o :user "bean-counter") :employee (o :building "hq")]
[(o :user "sysadmin") :employee (o :building "hq")]
[(o :user "nephew") :employee (o :building "hq")]
[(o :user "nephew") :suspended (o :building "hq")]
[(o :user "boss") :member (o :team "staff")]
[(o :user "programmer") :member (o :team "staff")]
[(o :user "bean-counter") :member (o :team "staff")]
[(o :user "sysadmin") :member (o :team "staff")]
[(o :user "intern") :member (o :team "staff")]
[(o :user "janitor") :member (o :team "staff")]
[(o :user "nephew") :member (o :team "staff")]
[(o :user "programmer") :member (o :team "ops")]
[(o :user "sysadmin") :member (o :team "ops")]
[(o :user "nephew") :member (o :team "ops")]
[(o :user "boss") :member (o :team "finance")]
[(o :user "bean-counter") :member (o :team "finance")]
[(o :building "hq") :building (o :area "office")]
[(o :team "staff") :team (o :area "office")]
[(o :building "hq") :building (o :area "server-room")]
[(o :team "ops") :team (o :area "server-room")]
[(o :building "hq") :building (o :area "vault")]
[(o :team "finance") :team (o :area "vault")]
[(o :area "office") :area (o :asset "computer")]
[(o :team "ops") :team (o :asset "computer")]
[(o :user "boss") :operator (o :asset "computer")]
[(o :user "sysadmin") :admin (o :asset "computer")]
[(o :area "server-room") :area (o :asset "mainframe")]
[(o :team "ops") :team (o :asset "mainframe")]
[(o :user "sysadmin") :admin (o :asset "mainframe")]
[(o :area "vault") :area (o :asset "treasury")]
[(o :team "finance") :team (o :asset "treasury")]
[(o :area "office") :area (o :asset "power")]
[(o :team "staff") :team (o :asset "power")]
[(o :user "intern") :barred (o :asset "power")]
[(o :area "office") :area (o :asset "coffee-machine")]
[(o :team "staff") :team (o :asset "coffee-machine")]
[(o :area "vault") :area (o :asset "bar-fridge")]
[(o :team "finance") :team (o :asset "bar-fridge")]
[(o :user "boss") :barred (o :asset "bar-fridge")]
[(o :user "programmer") :operator (o :asset "bar-fridge")]
[(o :user "nephew") :operator (o :asset "bar-fridge")]
[(o :area "office") :area (o :asset "jukebox")]
[(o :team "staff") :team (o :asset "jukebox")]
[(o :user "intern") :barred (o :asset "jukebox")]
[(o :area "server-room") :area (o :asset "servers")]
[(o :user "sysadmin") :operator (o :asset "servers")]]);; the rest, the page's trace of why and its way from the REPL to EACL, is
| Authentication (AuthN) | Authorization (AuthZ) |
|---|---|
Who are you?, i.e. who is the <subject>? | What can <subject> do? |
“EACL can efficiently answer questions like, "Can <subject> do <permission> on <resource>?"” — README
;; Click a form to run it at the prompt, against EACL HQ, and EACL answers in the REPL: shows the thin way from the REPL to it. The rest need Datomic, so they are EACL's own listings.
can? one decision(eacl/can? acl (->user "sysadmin") :enter (->area "server-room"))(eacl/can? acl (->user "boss") :enter (->area "server-room"))(eacl/can? acl (->user "intern") :operate (->asset "power"))check-permission the decision as data;; :allowed?, and :cached?: whether EACL's cache answered, and on which basis. The authorization logs time every answer, in orange where the cache had none.
(eacl/check-permission acl (->user "nephew") :enter (->area "office"))lookup-resources what can they reach?“Which <resources> does <subject> have <permission> on, as-of <10 seconds ago, or newer>?” — README
(eacl/lookup-resources acl
{:subject (->user "boss")
:permission :enter
:resource/type :area
:first 1});; A page at a time: the next begins :after the last one's :end-cursor, which EACL encrypts.
(let [page1 (eacl/lookup-resources acl
{:subject (->user "boss") :permission :enter
:resource/type :area :first 1})]
(eacl/lookup-resources acl
{:subject (->user "boss")
:permission :enter
:resource/type :area
:first 1
:after (get-in page1 [:page-info :end-cursor])}))lookup-subjects who can?(eacl/lookup-subjects acl
{:resource (->area "vault")
:permission :enter
:subject/type :user});; And who may unplug the computer: its admin, who may operate it.
(eacl/lookup-subjects acl
{:resource (->asset "computer")
:permission :unplug
:subject/type :user});; And who may leave the vault: anyone, user:*, SpiceDB's wildcard, one relationship that grants every user. EACL answers with the wildcard itself.
(eacl/lookup-subjects acl
{:resource (->area "vault")
:permission :exit
:subject/type :user
:caveat-context {"inventory" [] "sensitive" (sensitive)}})“SpiceDB does not support counting (you must traverse in pages), but EACL does.” — README
(eacl/count-subjects acl
{:resource (->area "office")
:permission :enter
:subject/type :user});; A relationship can carry :valid-until-ms. The intern's badge lasts a week this way, on the building's clock, which is EACL's. Give them another week:
(eacl/write-relationship! acl
{:operation :touch
:subject (->user "intern")
:relation :employee
:resource (->building "hq")
:valid-until-ms (at "00:00" 7)})“At the deadline it stops granting access, even if nothing is written to the database.” — docs/caveats.md
;; A relationship can carry a Caveat instead: a condition, in a subset of CEL, that the context of a question decides. The janitor's pass holds on Tuesdays and Thursdays. Ask without the day, and EACL answers that it depends, naming what it needs:
(eacl/check-permission acl (->user "janitor") :enter (->area "office"))(eacl/check-permission acl
{:subject (->user "janitor")
:permission :enter
:resource (->area "office")
:caveat-context {"today" (today)}});; And anyone may leave an area, user:*, but not with anything sensitive: the door passes what they carry, and the building's list of what is sensitive, (sensitive) at the prompt, which a click on an item in the building changes.
(eacl/can? acl
{:subject (->user "boss")
:permission :exit
:resource (->area "vault")
:caveat-context {"inventory" ["draft-tweets"]
"sensitive" (sensitive)}})“You can also preview changes without committing them” — README
(eacl/with-snapshot [s (eacl/snapshot acl)]
(let [tx (eacl/tx-relationship s :delete (->user "bean-counter") :member (->team "finance"))]
(eacl/with-snapshot [preview (eacl/with s tx)]
{:now (eacl/can? s (->user "bean-counter") :enter (->area "vault"))
:what-if (eacl/can? preview (->user "bean-counter") :enter (->area "vault"))})));; A snapshot is a value: reads on it never change, and nothing else sees the preview.
EACL stores both directions of a relationship. A native entity retraction removes the half stored on the target, but it cannot follow the peer ID stored inside the other endpoint's tuple or vector. The surviving half is a ghost relationship and can continue granting access.
— README
;; delete-object! removes every relationship of an object's, both ways, and leaves the entity be:
(eacl/delete-object! acl (->user "nephew"))“To retract an entity and its Relationships, use :eacl.fn/retractEntity, an optional Transactor function you can install.” — README
(require '[eacl.datomic.safe-retraction])
(eacl.datomic.safe-retraction/install! conn)
@(d/transact conn [[:eacl.fn/retractEntity [:app/id "report"]]])
(eacl/can? acl alice :view report) ; false;; from the README: EACL on Datomic, where :eacl.fn/retractEntity retracts the entity and its relationships in one transaction.
;; EACL has four consistency modes, named after SpiceDB's. Each picks the database snapshot a read uses, and some backends lack history for the last. Drag the Peer, which lags the transactor, and the token of a write you made:
;; a video's view can be a few seconds old
(def token-10s-ago (eacl.datomic/zed-token-at-least-seconds-ago acl 10))
(eacl/can? acl (->user "alice") :view (->video "my-video")
(eacl.spicedb.consistency/at-least-as-fresh token-10s-ago));; its deletion cannot
(eacl/can? acl (->user "alice") :delete (->video "my-video")
eacl.spicedb.consistency/fully-consistent) ; this will block on (d/sync conn);; from the README: EACL on Datomic. DataScript, which this page runs, reads the current database, as minimize-latency does.
;; Your data and its relationships, in one transaction:
(eacl/with-snapshot [snapshot (eacl/snapshot acl)]
@(d/transact conn
(eacl/tx-relationships snapshot
{:updates [{:operation :touch
:relationship (eacl/->Relationship alice :viewer report)}]
:tx-data [[:db/add [:app/id "report"] :document/title "Shared report"]]})));; from docs/atomic-writes.md
“What if you could compute exactly which users are affected by every DB write and notify only those clients, in real-time?” — README
;; a sketch, not a listing: notify only the clients that a write affects
(d/listen conn ::notify
(fn [{:keys [tx-data]}]
(doseq [doc (documents-touched-by tx-data)]
(->> (eacl/lookup-subjects acl {:resource doc :permission :view :subject/type :user})
:data
(filter online?)
(run! #(notify! % doc))))));; The building does the same: it listens to its DataScript database, and after each write asks EACL who may enter where, and says who changed.
I spent the better half of 2024 integrating SpiceDB at CloudAfrica.
- Keeping permission data synced to an external authorization system is non-trivial, especially if there is an impedance mismatch between your data model and SpiceDB's permission schema (3-tuple Relationships).
- SpiceDB write operations such as
WriteRelationshipsreturn ZedToken strings, which you can store alongside entities in your database to use the SpiceDB cache withat_least_as_freshandat_exact_snapshotconsistency semantics.- If you need to hit the DB (or cache) anyway to query Spice, you might as well situate your permission data in Datomic and avoid an external network hop as well as complex diffing & syncing operations – this is the promise of EACL.
Worried about load? You can horizontally scale Datomic Peers dedicated to authorization and even expose the EACL API to external consumers.
— README, Rationale
— README, Overview
- EACL operates at a different scale from SpiceDB:
- Spice is benchmarked against 100B Relationships
- EACL aims for ~10M Relationships or less in a situated environment – potentially 100M
- By adopting a ReBAC data model, you can avoid a rewrite later and easily migrate to SpiceDB when you achieve hyperscale.
“EACL's situated philosophy aligns with that of Datomic: if Data is local and Query is local, perception can scale, so why wait for an external AuthZ system to compute permissions?” — README
;; The building above is situated too: EACL and EACL HQ's DataScript database run in this page, and a door's question never leaves it.
Consider that to leverage
at_least_as_freshconsistency semantics in SpiceDB forLookupResources, you need to:
- Hit the DB or cache for the latest ZedToken pertaining to an entity,
- Pass the ZedToken to SpiceDB to retrieve a consistent page of object IDs,
- Hydrate entities from your database using those IDs.
In EACL, since Peers are locally-consistent, as long as database snapshot S (valid @ time T) is locally available, we can query immediately without a network hop, or reuse cached answers derived from S or newer.
— README, The Benefits of Situated Authorization
— the same list
- Consistency: Syncing to an external system introduces eventual consistency. With situated AuthZ, queries are at least locally-consistent as-of time
T.- Simple Syncing: Relationships are just 3-tuples of
[subject relation resource], so there is no impedance mismatch when syncing to SpiceDB at scale.- Real-time UI updates for materialized views: it is cheap to compute the subset of online clients that need to re-query while avoiding query amplification due to a busy Transactor.
- One less thing to deploy & sync Relationships to.
— README, Performance
- EACL is internally benchmarked against 1M Relationships, tested against a real-world, recursive schema with e2e latency @ ~1-40ms per query (incl. hydration), depending on schema & query complexity.
- EACL makes no strong performance claims at this time, but EACL should be as fast as, or faster than, SpiceDB, for small-to-medium workloads.
- As load increases, you can scale Datomic Peers horizontally and even dedicate Peers to EACL as-needed.
EACL first looks for an answer computed at database version 120. For a current database read, it can also reuse an earlier answer if the permission schema and all relationships that answer depends on are unchanged. Otherwise it computes the answer at version 120.
— README, Caching
;; The authorization logs time each answer, and show in orange those EACL's cache did not serve, from check-permission's own :cached?; a pointer over the time says which. The page's who-may-enter view asks with :populate-cache? false, so it reads the cache without filling it, and the doors' first answers stay EACL's own.
;; A check starts from the area it is asked about, not from everyone who might enter it. For a two-layer arrow, such as team->member, EACL decides from whichever side is smaller, and proves the bound (RoundsBoundedByShorterSide):
“the complexity property that makes a check on a widely shared resource cost the subject's few holdings rather than the resource's fan-in” — docs/formal-verification.md
— README, Formal Verification
- EACL is formally verified using Dafny, TLA+/TLC, and Apalache, but has not been independently audited or certified.
- The engine is heavily tested to never say "yes" when it should say "no".
Dafny
proves
the semantics: typed rules, normalization, monotone consequence, finite least fixed point
generates
the kernel that decides consistency planning, cursor continuation and page-request normalization: generated Java on the JVM, and in ClojureScript a twin checked against the generated JavaScript
TLA+ · TLC · Apalache
hunts
bounded models of hostile cache, cursor, snapshot and continuation histories
keeps
67 minimized witnesses, with their bug ledger
the engine
is checked
hand-written Clojure(Script) for checks, lookups and counts, against the models: executable refinement bridges and mutation controls
and against
SpiceDB, black-box
EACL's formal work proves a backend-neutral authorization kernel under named adapter, runtime, and cryptographic assumptions. It does not verify Clojure, ClojureScript, storage engines, compilers, cryptographic primitives, or a customer's policy intent.
— docs/formal-verification.md
A timeout or incomplete traversal is not cached as a denial.
— docs/permission-set-algebra.md
;; EACL is on Clojars: dev.eacl/eacl-datomic, for EACL on Datomic Pro, in your deps.edn. Datahike, DataScript and Datalevin have their own modules; the building above runs dev.eacl/eacl-datascript.
;; then, from the Quickstart:
(def acl (eacl.datomic/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (d/entity db eid)))}))
(eacl/write-schema! acl
"definition user {}
definition document {
relation viewer: user
relation owner: user
permission view = viewer + owner
permission share = owner
}")
@(d/transact conn
[{:app/id "alice"}
{:app/id "bob"}
{:app/id "report" :document/title "Report"}])
(def alice (eacl/spice-object :user "alice"))
(def bob (eacl/spice-object :user "bob"))
(def report (eacl/spice-object :document "report"))
(eacl/create-relationship! acl alice :viewer report)
(eacl/can? acl alice :view report) ; true
(eacl/can? acl bob :view report) ; falseTry the EACL Demo at
demo.eacl.devwith options for:
- Backend: Datomic Pro, Datahike, Datalevin or DataScript (in-browser)
- Storage: S3 or DynamoDB
- Execution: AWS Lambda or EC2 (t3.small instance)
EACL Drive is a toy clone of Google Drive that shows how easy it is to add fine-grained permissions to your app.
— README, Demos