authdsl

authdsl is a Go module for attribute-based authorisation using a human-readable
policy DSL.
This module contains the DSL compiler, an Evaluator allowing testing of DSL statements, and a fully working authorisation engine that tests access based on policies written in the DSL.
Policies are versioned in epochs — immutable snapshots of group membership and compiled rules served by a remote control infrastructure.
An embedded engine.Engine evaluates requests against the active epoch with no lock contention on
the hot path, and supports on-behalf-of (OBO) delegation chains and historic
re-evaluation for compliance workflows.
When trust providers are configured, SubjectIdentity fields carry signed subject
tokens (ED25519 or ML-DSA-87). EvaluateAsOfOBO uses split verification: the live
caller is checked against today's trust providers while the historic inner chain is checked
against the trust providers that were in effect at asOf, fetched from a dedicated
Historic Trust Service advertised by the control server.
Architecture
graph TB
subgraph proc["Your Application Process"]
code(["Application Code"])
subgraph sdk["authdsl module"]
eng["engine.Engine
─────────────────────
Epoch state machine
Evaluate / EvaluateForSession
EvaluateOBO / EvaluateOBOForSession
EvaluateAsOfOBO"]
re["remote.RemoteEngine
─────────────────────
ED25519/ML-DSA-87 key pair
ML-KEM-1024 response encryption
Registration + polling
Epoch lifecycle
LoadHistoricTrustProviders"]
end
code -- "Request →\n← Decision" --> eng
re -- "chan UpdateEpoch" --> eng
end
ctrl[["Control Server
─────────────────────
POST / register
GET / poll
GET /at historic
(advertises historic_trust_url)"]]
data[["Data Server
─────────────────────
GET /{id} groups
GET /{id}/policies/{p}
GET /{id}/obo_policies/{p}
GET /{id}/historic_auth_policies/{p}"]]
hts[["Historic Trust Service
─────────────────────
GET /historic_trust?t=...
(trust providers at time t)"]]
re -->|"signed + KEM-encrypted HTTPS"| ctrl
re -->|"verified HTTPS"| data
re -->|"signed HTTPS"| hts
ctrl -. "retrieve_url" .-> data
ctrl -. "historic_trust_url" .-> hts
authdsl.New creates and wires both components. remote.RemoteEngine handles all
network communication, key management, and epoch delivery. engine.Engine is the
evaluation surface exposed to your code — it never blocks on I/O during a request.
Quick Start
import (
"context"
"crypto/mlkem"
"net/url"
"time"
"gitlab.com/possumco/authdsl"
"gitlab.com/possumco/authdsl/engine"
)
// Load the server's ML-KEM-1024 encapsulation key (distributed out-of-band,
// alongside the control server's signing public key).
kemDecapKey, _ := mlkem.GenerateKey1024() // on the server side
kemEncapKey := kemDecapKey.EncapsulationKey() // share this with clients
ctrlURL, _ := url.Parse("https://control.internal")
eng, err := authdsl.New(ctx, authdsl.Config{
ControlConfig: authdsl.ControlConfig{
URL: *ctrlURL,
PublicKey: ctrlPub, // ED25519 or ML-DSA-87 signing key
KEMPublicKey: kemEncapKey, // ML-KEM-1024 encapsulation key (required)
ValidityDuration: 30 * time.Second,
},
ServiceConfig: authdsl.ServiceConfig{
Class: "order-service",
ID: "ord-1",
},
})
if err != nil {
log.Fatal(err)
}
defer eng.Close()
// Wait for the first epoch.
for {
if _, ok := eng.ActiveEpochID(); ok {
break
}
time.Sleep(10 * time.Millisecond)
}
// Direct evaluation.
dec, err := eng.Evaluate(ctx, &engine.Request{
SubjectIdentity: "alice",
Process: "document-store",
Action: "WRITE",
})
// dec == engine.DecisionAllow or engine.DecisionDeny
// On-behalf-of evaluation.
dec, err = eng.EvaluateOBO(ctx, &engine.OBORequest{
SubjectIdentity: "agent-alice",
Inner: engine.For(&engine.Request{
SubjectIdentity: "customer-charlie",
Process: "customer.portal",
Action: "UPDATE",
}),
})
// Historic re-evaluation.
dec, err = eng.EvaluateAsOfOBO(ctx, &engine.OBORequest{
SubjectIdentity: "auditor-alice",
Inner: engine.For(&engine.Request{
SubjectIdentity: "trader-dave",
Process: "payments.ledger",
Action: "READ",
}),
}, twoYearsAgo)
Capabilities
Policy DSL
Policies are written in a structured, human-readable language. A policy file contains
ALIAS declarations (for reuse) followed by POLICY blocks, each of which is
homogeneously ALLOW or DENY:
POLICY allow-admins ALLOW {
group:admins TO PERFORM ANY_ACTION ON process:document-store ;
}
POLICY readers-read-only ALLOW {
group:readers TO PERFORM READ ON process:document-store ;
}
POLICY support-obo ALLOW {
group:support-staff TO PERFORM system.obo:ACT_AS FOR group:customers ;
}
Subjects, actions, and processes support boolean expressions (AND, OR, NOT),
group membership (group:name), wildcards (ANY, ANY_ACTION, ANY_PROCESS), and
UUID references. The WHERE clause adds attribute and context conditions
(IS_OBO, IS_HISTORIC, OBO_DEPTH, TAG, SUBJECT.xxx, REQUEST.xxx). The
DURING clause adds temporal constraints (FROM, UNTIL, BETWEEN, DAYS, HOURS).
Policies are compiled once — dsl.Compile(src) returns an immutable CompiledPolicy
safe for concurrent use.
See tech_docs/dsl.md for the full language reference.
Evaluation
Five evaluation methods are available on engine.Engine:
| Method |
Use case |
Evaluate(ctx, *Request) |
Direct authorisation: is this subject allowed to perform this action? |
EvaluateForSession(ctx, *Request) |
Direct authorisation + FixedEpoch: pin to the active epoch for session consistency |
EvaluateOBO(ctx, *OBORequest) |
Delegation chain: can this agent act on behalf of that principal? |
EvaluateOBOForSession(ctx, *OBORequest) |
Delegation chain + FixedEpoch: pin to the active epoch for OBO session consistency |
EvaluateAsOfOBO(ctx, *OBORequest, asOf) |
Historic re-evaluation: was this action authorised at a past point in time? |
All methods resolve group memberships from the epoch snapshot automatically — no
group data needs to be set on the request by the caller. Evaluation never acquires a
mutex on the hot path.
EvaluateForSession and EvaluateOBOForSession return a FixedEpoch alongside the
decision. A FixedEpoch evaluates all subsequent requests against the epoch that was
active when the session started, providing consistency even as the engine transitions to
newer epochs. It is tied to the SubjectIdentity of the initial request; attempts to
use it with a different subject return ErrInvalidRequest.
EvaluateAsOfOBO performs a three-phase check: authority verification against the live
epoch, historic epoch load, and inner evaluation. The calling subject must hold a
system.historic:EVALUATE grant in the current epoch before the historic epoch is
ever fetched.
See tech_docs/eval.md for the full API reference.
Epochs
An epoch is a versioned, immutable snapshot of group memberships and policy rules.
The engine always evaluates against the active epoch. When the remote control server
announces a new epoch, the RemoteEngine fetches the data and delivers it to the engine,
which atomically replaces the active state. In-flight evaluations against the previous
epoch complete uninterrupted.
Each RemoteEngine instance:
- Generates an ED25519 or ML-DSA-87 key pair on startup.
- Registers with the control server (
POST /), sending its service class, instance ID,
hostname, and public key. The server responds with a process_id for the session.
- Polls the control server (
GET /?last={n}) at the server-specified interval. When the
epoch ID changes, the new epoch data is fetched from the data server.
- Signs every outgoing control request with its private key; verifies every control
response with the server's public key. Control responses are also ML-KEM-1024 encrypted
— each request includes a fresh KEM ciphertext; the server encrypts the response body
with the derived shared key before signing.
Policy text is fetched lazily from the data server on the first evaluation for each
process and cached in-memory for the epoch's lifetime.
For historic EvaluateAsOfOBO calls, a separate GET /at?t=<RFC3339Nano> request
identifies the epoch that was active at the requested time, which is then fetched and
cached with a configurable TTL (default 5 minutes).
See tech_docs/control_service.md,
tech_docs/data_service.md, and
tech_docs/engine.md for detailed specifications.
Encryption
Control server responses carry public key material that is later used to verify epoch
signatures. To protect this material against harvest-now-decrypt-later attacks, every
control response body is encrypted using a ML-KEM-1024 / AES-256-GCM hybrid scheme:
- The client generates a fresh ML-KEM-1024 ciphertext from the server's pre-configured
encapsulation key and sends it in the
X-KEM-Ciphertext request header.
- The server decapsulates the ciphertext to obtain the 32-byte shared key, encrypts the
JSON response body with AES-256-GCM (12-byte nonce prepended), and signs the
encrypted body with its ED25519 or ML-DSA-87 private key.
- The client verifies the signature over the encrypted body, then decrypts using the same
shared key.
Each request uses a freshly encapsulated shared key, providing per-request forward
secrecy: a future quantum adversary who recorded TLS traffic cannot recover the key
material used for response signatures.
KEMPublicKey in ControlConfig is required and must be pre-distributed alongside the
control server's signing public key.
See tech_docs/crypto.md for the full cryptographic specification.
Observability
Both engine and remote are instrumented with OpenTelemetry Go (API only — no SDK bundled). Register any TracerProvider before calling evaluation methods and spans will be emitted automatically:
import (
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(yourExporter))
otel.SetTracerProvider(tp)
defer tp.Shutdown(ctx)
Key span names: authdsl.Evaluate, authdsl.EvaluateOBO, authdsl.EvaluateAsOfOBO, authdsl.EvaluateForSession, authdsl.EvaluateOBOForSession, authdsl.FixedEpoch.Evaluate, authdsl.FixedEpoch.EvaluateOBO, authdsl.remote.fetchEpochData, authdsl.remote.LoadPolicy, authdsl.remote.LoadOBOPolicy, authdsl.remote.LoadHistoricAuthPolicy, authdsl.remote.updateEpoch, authdsl.remote.fetchEpochAt, authdsl.remote.LoadHistoricEpoch, authdsl.remote.LoadHistoricTrustProviders.
Spans carry authdsl.process, authdsl.action, authdsl.epoch_id, authdsl.decision, and authdsl.as_of attributes. Remote HTTP spans are SpanKindClient and capture full retrieval round-trip duration including signature verification.
See tech_docs/observability.md for the full span inventory and attribute reference.
Demos
Six self-contained demo programs are included:
| Program |
What it shows |
go run ./cmd/demo |
Live epoch transition: group membership changes mid-run |
go run ./cmd/session |
Session consistency: FixedEpoch preserves epoch-1 decisions after transition to epoch 2 |
go run ./cmd/obo |
Single-hop and two-hop OBO delegation chains |
go run ./cmd/historic |
Historic re-evaluation with authority checking |
go run ./cmd/negotiation |
Algorithm negotiation: ED25519 and ML-DSA-87 trust providers both accepted (AlgorithmPolicyBoth) |
go run ./cmd/mldsa_tokens |
Algorithm enforcement: AlgorithmPolicyMLDSA87Only rejects ED25519 tokens at evaluation time |
Each demo starts its own in-process control and data servers so no external
infrastructure is required.
Technical Documentation
| Document |
Contents |
tech_docs/dsl.md |
Full DSL grammar, all expression types, ALIAS, temporal constraints, namespaces, compile API, diagnostics |
tech_docs/eval.md |
Evaluation API, Request/OBORequest types, group resolution, context flags, all error conditions |
tech_docs/engine.md |
Epoch state machine, historic caching, concurrency model, shutdown |
tech_docs/control_service.md |
Control server protocol, wire types, KEM-encrypted responses, request signing, key rotation, freshness checks |
tech_docs/data_service.md |
Data server endpoints, epoch data structure, policy caching, response signing |
tech_docs/crypto.md |
Key algorithms (ED25519, ML-DSA-87), ML-KEM-1024 encryption flow, wire key format, algorithm enforcement rules |
tech_docs/wire_types.md |
Full wire type reference: RegistrationRequest, ControlResponse, EpochAtResponse, WireTrustKey, tagged key format |
tech_docs/observability.md |
OpenTelemetry span inventory, attribute reference, TracerProvider wiring |
Testing
go test ./... -race -count=1