Licetum: explainable access rules over your services' resource hierarchy
Licetum answers "what can this user see and change?" for several services at once. Resources stay in the services that own them. Licetum keeps a projection of their hierarchy (ids, names, parent links, and the attribute
Licetum answers "what can this user see and change?" for several services at once.
Resources stay in the services that own them. Licetum keeps a projection of their hierarchy (ids, names, parent links, and the attributes your sync query chooses to return), plus rules over it: allow and deny, exceptions, expiry, roles, groups, and conditions on token claims. A service asks a question and gets either a list of allowed resource ids or a yes/no. Every answer can be traced to the rules behind it.
It is not a policy language like Rego or Cedar, and not a relationship store like SpiceDB or OpenFGA. It is narrower: a resource hierarchy that can span services, fed by SQL or HTTP sources you already have, with an admin UI, history with undo, policy lint and tests.
The limits, stated up front: the projection lags the sources. By default a type is re-fetched at most every 30 seconds, and only while requests arrive. Synchronization fetches full lists. An empty answer from a source is believed, so a broken query withdraws access. Performance numbers so far are preliminary, from one laptop and a small policy.
Licetum
/
licetum
Licetum โ centralized authorization service. Client services register themselves, describe their resource type hierarchy, and ask "can subject X do Y on resource Z?" instead of implementing their own RBAC. Supports RBAC/ABAC rules over token claims, role membership, resource sync from owning services, and the OpenID AuthZEN evaluation API.
Licetum
Languages
English ยท ะ ัััะบะธะน ยท ็ฎไฝไธญๆ
Connect your services once. Ask once. Explain every answer.
Licetum is a central authorization service for microservice teams. Each service publishes its resources once. Any consumer then asks one question, what can this user access?, and gets an answer it can apply to its own API, UI or database query. Every answer can be explained. The name comes from the Latin licet "it is permitted".
Why Licetum
In a microservice architecture, permissions are spread across databases, config files and home-grown services. Every service that needs to know "which projects can this user see?" ends up with its own endpoint, its own export format and its own reading of the rules. The copies drift apart: one service shows a project that another one hides.
Licetum replaces the copies with one contract:
- The owning service keeps its data and decides which of itsโฆ
What it is for
Several services, each with its own idea of who may see what, and a hierarchy that crosses them (a space in one product contains chats of another). With one service, use your framework's authorization. For very large relationship graphs, look at SpiceDB or OpenFGA first. Licetum targets the middle ground: a handful of services with existing databases, rules driven by identity-provider claims, and admins who need to change access without a deploy.
How it compares
SpiceDB and OpenFGA store relationships you write into them, and they have consistency tools (ZedTokens, contextual tuples). If you already maintain tuples, they are the more general answer. OPA, Cedar and Cerbos are decision engines: they evaluate policy over attributes you pass in, and the hierarchy and its data remain your responsibility. Casbin is a library that runs inside your process.
Licetum takes a different axis: it maintains the resource hierarchy by pulling it from your sources, and adds the rule workflow on top. It is less general than all of these. We have not benchmarked against any of them.
How it works
Each service registers its identity provider (issuer and JWKS URL) and its resource types. A type has a synchronizer: a SQL query or an HTTP endpoint that lists its resources with their parent links. Licetum pulls those lists and stores a projection: ids, names, parent links, and every extra column or field the query returns, up to 32 attributes per resource. It keeps a transitive closure of ancestors in PostgreSQL. A decision never calls the source per resource. A type can have several parents, and links can cross services. Each link says which access levels flow along it.
What is not one source of truth
The owning service is authoritative for what exists and where it sits. Licetum's projection is a copy, and it lags behind.
- A type is answered from its last synchronization (consistency
minimize_latency) and re-fetched at most every 30 seconds, but only while requests arrive. After an idle period, the first answer comes from the last synchronization, however old that is. The 30-second figure limits the refresh rate; it does not bound staleness. - The list a source returns is also kept in Redis for
cache_ttlseconds (10 by default, configurable per synchronizer;0disables it). With steady traffic, the lag is roughly 30 s pluscache_ttlplus the refresh time. - Any admin write resets the throttle, so frequent rule changes also trigger more synchronizations.
- Rule changes take effect at once, because every admin write bumps the cache version.
We tested the consequence: delete a resource at the source, and a check still returns allowed until a synchronization sees the deletion. With consistency=higher, the same check returns resource_not_found. For sensitive checks, use higher. This is a trade against dual writes, not a way around them.
Failure modes, as they behave today
- The fetch timeout is 10 seconds. A timed-out source counts as unanswered, and its data stays as it was. A breaker opens after 5 failures within 60 seconds and stays open for 30 seconds. The 30-second throttle is set only after a successful sync, so while a source keeps failing, later requests retry it, bounded by the breaker.
- Sync always fetches the full list; there are no deltas. We measured a 10,000-resource list at about 25 ms on average in our test setup. We have no measurements at 100,000 resources.
-
An empty answer is believed. If a broken query returns zero rows, access to that type's resources and everything below them disappears until the query is fixed. We reproduced this. It fails toward no access, not toward leaking, but it is an outage. There is no guard against a sudden drop yet. Licetum exposes no row-count metric, so watch the row count of your own source query. The
licetum_source_sync_totalcounter (changed, unchanged, unanswered) shows when a sync changes data. - A resource whose parent is not yet known: allow rules that reach it still apply. Denials that could reach it through the missing parent are assumed to apply until the parent appears. The explanation shows
unresolved_parent.
Threat model
-
Callers. A service's backend forwards the user's bearer token. Licetum checks its signature, issuer and expiry against that service's JWKS. Alternatively, a trusted backend client asks on behalf of a person. Its client id must be listed in the service's
trusted_clients, and each such call is audited asdelegated_call. -
Not checked: the token's audience (
aud), and revocation. Trust rests on the issuer and the JWKS. Licetum does not call an introspection or revocation endpoint (checked in the code). A token revoked at the identity provider keeps working until its own expiry, so use short-lived access tokens. Verified tokens are cached for up to 300 seconds, never beyond their expiry. -
Id lists. A user's token returns only that user's own list. Lists for other people exist only through
on_behalffor trusted clients and through the admin preview, both behind policy. -
Compute cost.
/permissioncomputes everything reachable for a subject when the cache is cold. The servers have no rate limit and no response-size cap. Put a gateway in front of them, and use/checkfor point decisions. -
Control plane. Anyone with database access, or a shell on the admin host, controls all rights. The admin API is itself protected by Licetum, so
restore-adminis the break-glass path. Treat the database as the critical asset.
Rules
Allow and deny, with validity periods, nested groups (depth cap 16), roles as named permission sets, and conditions on claims, request context and the clock (hour and weekday, in UTC). A rule can target a person, a group, a claims predicate, or a resource found from a claim at check time.
Deny wins. An exception is an allow rule that lifts a denial only for resources strictly below it. The deepest denial or exception decides, and ties go to the denial. Level * in a denial removes every level. Example: allow read on the whole type branch, and deny * on branch B2, leaves every branch except B2.
Matching by name
match_by defaults to name. That match is exact and case-sensitive, and names are not unique, so two resources with the same name both match. Prefer id. This example was run as an integration test:
read on the organization whose id equals the caller's "org" claim -> everyone
write on the same organization, only if "is_manager" is "true" -> everyone
With org set to a branch id, staff get read, managers get read and write, and other organizations get nothing. A claim with a name value matches nothing against an id rule, so the rule fails safe.
Explanations
POST /service/{service}/check with explain: true returns the decision and the rules behind it. Below is a real answer, with ids shortened. It asks about an item of service app reached through a rule on a division of service org:
{
"allowed": true,
"explanation": {
"decision": "allowed",
"denials": [],
"granted_by": [{
"rule_id": "6ec9โฆ", "effect": "allow", "exception": false,
"access": ["read"],
"anchor": { "type": "division", "resource_id": "D1" },
"applies_to": "subject user-1"
}]
}
}
The explanation names the anchor type. It does not name the owning service or show the path from the anchor to the resource. Those are gaps. When a denial applies, denials carries the denying rule, its level, and lifted_by.
Operating it
Changes to rules, groups, memberships, roles and access sets are logged with before and after values. They can be reverted one by one, or rolled back to a point in time, with a dry run available. A revert writes the earlier version back; it does not merge with later edits. The UI offers ten seconds of undo. Changes to services, types and synchronizers are logged but cannot be reverted.
Policy exports to a JSON document, imports with a dry-run plan, can be linted (dead rules, grants to everyone, unused roles, and more), and is tested with scenarios. Policy tests run over HTTP; there is no CI command yet. A change that would leave nobody able to edit rules is refused.
Performance (preliminary)
Two 10-minute windows on one laptop (Core i5-12450H; PostgreSQL 16 and Redis 7 in containers), each after a 60-second warm-up, at a steady 200 requests/s. Each window had 120,001 requests, all 2xx, none dropped. The policy: 10,000 resources, 1,000 rules, 10 groups, 200 subjects. Consistency minimize_latency. Latency in milliseconds, run 1 / run 2:
| Operation | Samples | p50 | p99 |
|---|---|---|---|
| permission | 24,048 / 23,498 | 2.34 / 2.36 | 13.5 / 13.8 |
| check, 1 question | 36,051 / 36,207 | 1.77 / 1.79 | 10.4 / 10.2 |
| check, 10 questions | 11,938 / 12,068 | 10.5 / 10.6 | 22.2 / 21.3 |
| AuthZEN evaluation | 24,070 / 24,190 | 1.77 / 1.79 | 10.0 / 10.3 |
| AuthZEN batch | 11,899 / 12,128 | 10.5 / 10.6 | 21.7 / 20.8 |
| on-behalf check | 11,995 / 11,910 | 3.99 / 4.02 | 14.0 / 14.4 |
The permission cache hit rate was 96.7%; the check hit rate was about 72%, which includes AuthZEN evaluation. That follows from the workload's 200 subjects, not from the system. A full computation after a cache miss averaged about 10 ms here. We believe 10-question checks and AuthZEN batches mostly take that path, because their question sets rarely repeat, but we have not labelled hits and misses per operation.
What this is not: a capacity figure or a scaling law. We have no valid window at 100,000 resources and no cold-cache series. Two runs do not give a distribution. Please treat these numbers as a first measurement and repeat them on dedicated hardware.
Correctness
The decision semantics are covered by integration tests: denials, exceptions, several parents, per-link level filters, level implications, expiry, unresolved parents, claim-based targets and delegation. We have not yet done an independent comparison of answers against a reference implementation.
License
The core is MIT: the engine, the check API and its OpenAPI description (core/schema/check.yaml), the database schema and migrations, the models (including the policy document format licetum.policy/v1), and the synchronizer contract. Client libraries, source adapters, CI tooling and other front ends can be built on it. SDKs for Go, TypeScript and Python are planned and not yet published.
The admin API and UI are AGPL-3.0-only. We drew the line there because the admin side is the costly part: rule management with guards, history and rollback, previews, lint, policy import and export, self-protection, and a UI in three languages. The AGPL requires anyone who modifies that code and offers it to users over a network to publish the modifications, which keeps the admin side from being taken into a closed fork. Running it unmodified as a separate service does not oblige your own code to be AGPL. This is the common reading, not legal advice. You are free to write your own admin on the MIT core.
GitHub shows the license as "Other" because the repository has parts under two licenses; each part has its own LICENSE file.
Try it
With Docker and Task installed, task dev starts PostgreSQL, Redis, a local Dex OIDC provider, both APIs and the web interface. Open http://localhost:3000 and sign in as [email protected] / password. These credentials are for development only; do not expose the stack to the internet with them. The integration tests under admin-api/tests/integration/api are the most precise description of the semantics.
I would most like feedback on two things: the design of a guard against empty or shrinking syncs, and whether minimize_latency is the right default for an authorization system.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.