Go SDK for Atol — authentication and authorization for developers who ship. One SDK, one dashboard. Embeds JWT validation, OPA policy evaluation, and Zanzibar relationship checks in-process for <250us authorization decisions with no network I/O on the hot path.
The SDK runs inside your service and handles authentication and authorization locally. It connects to atol.sh at startup to pull your configuration, then makes all decisions in-memory. The source is open so you can see exactly what runs in your infrastructure.
import sdk "atol.sh/sdk-go"Sign up at atol.sh. Create an organization — this is your tenant in Atol and the scope for all users, roles, and policies.
In the dashboard sidebar, open APIs and click New API Key to create a server-to-server key (HMAC-signed). You'll need:
| Credential | Where to find it | What it's for |
|---|---|---|
| Control plane URL | Settings > General (your org's subdomain) | https://<org-slug>.atol.sh (ControlPlaneURL in the SDK config); also your OIDC issuer |
| Org ID | Settings > General (ORG ID) | Identifies your organization (StoreID in the SDK config) |
| Key ID | APIs > New API Key | Publishable credential (KeyID in the SDK config) |
| Secret Key | APIs > New API Key | Private credential, shown once (SecretKey in the SDK config) |
| JWKS URL | Auto-derived from ControlPlaneURL |
Validates user JWTs; override via JWKSUrl only if your issuer differs |
In the dashboard, go to Authorization > Model and define your resource types and relationships:
types:
user: {}
organization:
relations:
owner:
types: [user]
member:
types: [user]
document:
relations:
org:
types: [organization]
editor:
union:
- from: org
lookup: owner
- from: org
lookup: member
viewer:
union:
- editorEach relation either lists the subject types that can be assigned directly
(types: [user]) or derives from other relations (union:). A union entry
that is a plain string references another relation on the same type; a
{from, lookup} entry walks a linked object (here: a document's org) and
checks a relation there.
In Authorization > Rules, write Rego rules that use zanzibar.check() as a built-in.
The engine evaluates these query paths, in order, and uses the first one that
is defined:
data.atol.access.<resource_type>.allowdata.atol.allowdata.atol.access.<resource_type>.decisiondata.atol.decision
So your policy must live under package atol (or package atol.access.<type>),
and the input carries input.user, input.relation, and input.object
(plus input.resource_type, input.resource_id, and the enriched fields
from Authorize() such as input.plan and input.mfa_verified):
package atol
import rego.v1
default allow := false
allow if {
input.relation == "editor"
zanzibar.check(input.user, "editor", input.object)
}
allow if {
input.relation == "viewer"
zanzibar.check(input.user, "viewer", input.object)
}If no query path is defined for an input, the SDK falls back to a bare Zanzibar relationship check. An evaluation error never falls back — it is surfaced to the caller and the request is denied.
go get atol.sh/sdk-gopackage main
import (
"context"
"log"
"net/http"
"os"
sdk "atol.sh/sdk-go"
atolmw "atol.sh/sdk-go/middleware"
)
func main() {
atol, err := sdk.New(sdk.Config{
ControlPlaneURL: "https://acme.atol.sh", // your per-tenant subdomain (org slug)
KeyID: os.Getenv("ATOL_KEY_ID"),
SecretKey: os.Getenv("ATOL_SECRET_KEY"),
StoreID: os.Getenv("ATOL_ORG_ID"),
})
if err != nil {
log.Fatal(err)
}
defer atol.Close()
// Bootstrap: pulls your model, tuples, and policy bundle from atol.sh.
if err := atol.Bootstrap(context.Background()); err != nil {
log.Fatal(err)
}
// Middleware validates JWTs (issued by atol.sh) and sets the principal in context.
mux := http.NewServeMux()
mux.Handle("/api/", atolmw.HTTPMiddleware(atol)(apiHandler))
log.Fatal(http.ListenAndServe(":8080", mux))
}Set your credentials as environment variables — never hardcode them:
export ATOL_KEY_ID=atol_kid_... # from dashboard APIs > New API Key
export ATOL_SECRET_KEY=atol_sk_... # shown once on creation, never again
export ATOL_ORG_ID=org_01JQXYZ # from dashboard Settings > General┌─────────────────────────────────────────────────────┐
│ Atol (managed control plane) │
│ Dashboard: app.atol.sh │
│ SDK endpoint: <org>.atol.sh │
│ │
│ You configure: │
│ Users, orgs, roles → managed via dashboard/API │
│ Authorization model → YAML in dashboard │
│ Policies → Rego in dashboard │
│ Audit logs → viewable in dashboard │
└──────────────┬──────────────────────┬───────────────┘
│ bootstrap │ sync (streaming)
│ (model + tuples + │ (live mutations)
│ policy bundle) │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Your Service (with atol-sdk-go embedded) │
│ │
│ ├─ Token Validator (JWKS from atol.sh) │
│ ├─ OPA Engine (policy bundle from bootstrap) │
│ ├─ Zanzibar Engine (tuples from bootstrap + sync) │
│ ├─ Decision Logger (async → atol.sh) │
│ └─ State Sync (streams mutations) │
│ │
│ sdk.Check(ctx, "editor", "doc:readme") → <250us │
└─────────────────────────────────────────────────────┘
Startup: Bootstrap() pulls the authorization model, relationship tuples, and compiled OPA policy bundle from atol.sh via gRPC.
Runtime: Check() / Can() / Authorize() evaluate locally in memory. No network calls on the hot path.
Live sync: After bootstrap, the SDK streams mutations (tuple writes/deletes, model updates, policy updates) in real-time. When you grant a role in the dashboard, connected SDKs see it within milliseconds.
Decision logs: Authorization decisions are buffered locally and flushed asynchronously to atol.sh for the audit timeline.
JWT validation: User tokens issued by atol.sh are validated locally via JWKS. The SDK caches the signing keys and refreshes them automatically.
Everything the SDK holds is an in-memory replica of control-plane state. There is no SDK-side
database, nothing is written to disk, and there is no store to configure. If the process restarts,
it rebuilds on the next Bootstrap() and replays anything it missed — so there is nothing local to
back up.
| Held in the SDK | Source of truth | Kept fresh by |
|---|---|---|
| Relationship tuples + authorization model | atol.sh control plane | bootstrap snapshot, then live mutation stream |
| OPA policy bundle + policy data | atol.sh control plane | bootstrap, then live mutation stream |
| Revoked-session list (CRL) | atol.sh control plane | polled every 30s |
| JWKS signing keys | atol.sh control plane | fetched on demand, auto-refreshed |
| Decision logs | not stored — shipped out | async flush to atol.sh; dropped if the buffer is full |
| Materialized tuples | your application's database | your RegisterMaterializer callbacks; never sent to atol.sh |
Two consequences worth knowing:
- The store is deliberately not pluggable (there is no
WithStoreoption). The in-memory store is a throwaway replica of control-plane state; injecting your own backend would mean reimplementing the bootstrap and sync contract. App-specific relationships have their own path in —RegisterMaterializerand context tuples — and that data stays in your process. - Your data stays yours. A decision is computed locally and never round-trips. Only writes (grant/revoke) and audit events go to the control plane; materialized tuples derived from your own data never leave the process.
Atol is also your identity provider. Your frontend redirects users to atol.sh for login (OIDC Authorization Code flow), and your backend validates the resulting JWT with the SDK middleware.
Frontend — redirect to Atol login:
https://acme.atol.sh/authorize?
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
response_type=code&
scope=openid profile email
Configure your OIDC client in the dashboard under Settings > Authentication. You'll get a client_id and can set allowed redirect URIs.
Backend — the SDK middleware handles the rest:
// Middleware extracts the Bearer token, validates it against atol.sh JWKS,
// and sets the authenticated principal in the request context.
handler = atolmw.HTTPMiddleware(atol)(handler)
// In your handler, the principal is available:
p, _ := sdk.UserFromContext(ctx)
fmt.Println(p.UserID) // "usr_01JQXYZ"
fmt.Println(p.Email) // "user@example.com"
fmt.Println(p.OrgID) // "org_01JQXYZ"
fmt.Println(p.Roles) // ["admin"]
fmt.Println(p.Plan) // "pro"
fmt.Println(p.AuthMethod) // "oidc"// From request context (uses the principal set by middleware):
allowed, err := atol.Check(ctx, "editor", "document:doc-123")
// Explicit user + relation + object:
allowed, err := atol.Can(ctx, "user:usr_01JQXYZ", "owner", "organization:org-456")
// Full OPA evaluation with enriched context (roles, plan, auth method, MFA):
decision, err := atol.Authorize(ctx, "publish", "post:post-789")
if decision.Allow {
// proceed
}Manage relationships through the Atol dashboard or API:
# Grant editor access via API
curl -X POST https://acme.atol.sh/api/v1/access/grant \
-H "X-Atol-Key: $ATOL_KEY_ID" \
-d '{"org_id": "org_01JQXYZ", "user": "user:usr_abc", "relation": "editor", "object": "document:doc-123"}'Changes sync to connected SDKs in real-time — no restart needed.
Overlay ephemeral relationships for a single check without persisting them:
allowed, err := atol.Can(ctx, "user:alice", "viewer", "patient:p-123",
sdk.WithContextTuples(
model.Tuple{User: "patient:p-123", Relation: "provider", Object: "user:alice"},
),
)Register callbacks that produce tuples from your app's own data at bootstrap time. Useful for relationships you already track in your database:
atol.RegisterMaterializer("org-hierarchy", func(ctx context.Context) ([]model.Tuple, error) {
// Query your database for org membership and return as tuples.
// These tuples live in SDK memory only — never sent to atol.sh.
return tuples, nil
})Built-in middleware for HTTP, gRPC, and Connect-go:
import atolmw "atol.sh/sdk-go/middleware"
// net/http
handler = atolmw.HTTPMiddleware(atol)(handler)
// gRPC
grpc.NewServer(grpc.UnaryInterceptor(atolmw.GRPCUnaryInterceptor(atol)))
// Connect-go
connect.WithInterceptors(atolmw.ConnectInterceptor(atol))The SDK accepts both Bearer and DPoP authorization schemes. For a DPoP
request it validates the proof JWT and binds it to the access token's cnf.jkt,
so a stolen token cannot be replayed without the matching proof key. DPoP proof
validation runs only on the HTTP middleware path (the gRPC and Connect
interceptors reject DPoP-bound tokens with an explicit error). Set
Config.RequireDPoP = true to reject plain Bearer tokens entirely once all your
clients are DPoP-capable.
With Config.Device.Enabled = true, the middleware reads the X-Atol-Device-Id
header (set by the Atol client JS SDK) and exposes device signals to your Rego
policies as input.device.* — so a policy can deny detected bots or
high-anomaly sessions even when valid credentials are presented. The
auto-generated default policy already enforces this:
device_blocked if input.device.signals.bot == true
device_blocked if input.device.signals.anomaly_score > data.atol.device_max_anomaly_scoreThe scoring itself happens in the control plane; the SDK only carries the
signals. atol.DriftDetector() additionally compares each authenticated
request against the device bound to its session and reports divergences (a
changed user-agent family, or a missing fingerprint on a script-initiated call)
to the control plane without blocking the request.
The encryption package derives a Key Encryption Key from a user password
(Argon2id over an HKDF-SHA256 salt bound to the user and client) and wraps or
unwraps a Data Encryption Key with AES-256-GCM. The password and DEK never
leave the process; only the wrapped DEK is stored.
kek, err := encryption.DeriveKEK(password, userID, clientID)
dek, err := encryption.UnwrapDEK(wrappedDEKBase64, kek)sdk.Config{
// Required
ControlPlaneURL: "https://acme.atol.sh", // <org-slug>.atol.sh; also the OIDC issuer + JWKS base
KeyID: "atol_kid_...", // Publishable key ID from the dashboard
SecretKey: "atol_sk_...", // Secret key (shown once on creation)
StoreID: "org_01JQXYZ", // Your org ID from the dashboard
// Optional
Issuer: "", // Expected JWT iss claim. Defaults to ControlPlaneURL
Audience: "", // Expected JWT aud claim. When set, tokens must carry it
JWKSUrl: "", // Auto-derived: ControlPlaneURL + /.well-known/jwks.json
ZanzibarModelPath: "", // Local model YAML loaded at New(); bootstrap can replace it
BootstrapTimeout: 10 * time.Second, // Applied to Bootstrap() via context timeout
DisableSync: false, // Zero value = live mutation streaming ON
DecisionLogFlushInterval: 5 * time.Second,
DecisionLogBufferSize: 10000,
RequireDPoP: false, // true = reject Bearer tokens, mandate DPoP proofs
Device: device.Config{Enabled: true}, // Inject X-Atol-Device-Id into input.device.*
}Issuer covers the case where the control plane's network address differs
from the token issuer (e.g. Docker: ControlPlaneURL=host.docker.internal:9080,
Issuer=localhost:9080). Device is the atol.sh/sdk-go/device package.
Inject a logger to surface background failures (CRL refresh, decision log flush, sync disconnects) — the default is a no-op logger:
atol, err := sdk.New(cfg, sdk.WithLogger(zapLogger))| Package | Import | Purpose |
|---|---|---|
| Root | atol.sh/sdk-go |
sdk.New(), Check(), Can(), Authorize(), Bootstrap() |
| Middleware | atol.sh/sdk-go/middleware |
HTTP, gRPC, Connect-go auth middleware |
| Testing | atol.sh/sdk-go/atoltest |
Test engine, token factory, context builder, mock OIDC server |
| Zanzibar | atol.sh/sdk-go/zanzibar |
Relationship engine: model, check, tuple store |
| Policy | atol.sh/sdk-go/policy/engine |
OPA engine with zanzibar.check() built-in |
| Identity | atol.sh/sdk-go/identity |
JWT claims types, JWKS fetcher |
| Device | atol.sh/sdk-go/device |
Device-intelligence context + session drift detection |
| Encryption | atol.sh/sdk-go/encryption |
Zero-knowledge KEK derivation + DEK wrap/unwrap |
| Bootstrap | atol.sh/sdk-go/bootstrap |
State initialization from atol.sh |
| Decision | atol.sh/sdk-go/decision |
Async decision log buffer + sink |
| Sync | atol.sh/sdk-go/sync |
Live mutation streaming from atol.sh |
The atoltest package provides everything you need to test code that depends on the Atol SDK — no control plane, no external services, no Docker.
import "atol.sh/sdk-go/atoltest"Create a test engine with your authorization model, pre-populate tuples, and run checks:
func TestDocumentAccess(t *testing.T) {
engine := atoltest.NewEngine(t,
atoltest.WithModelFile("testdata/model.yaml"),
atoltest.WithTuples(
atoltest.Tuple{User: "user:alice", Relation: "owner", Object: "document:doc-1"},
atoltest.Tuple{User: "user:bob", Relation: "editor", Object: "document:doc-1"},
),
)
ctx := atoltest.Context().WithUser("alice").Build()
allowed, err := engine.Check(ctx, "viewer", "document:doc-1")
if err != nil {
t.Fatal(err)
}
if !allowed {
t.Error("alice should be a viewer (owner → viewer via union)")
}
}The engine returned by NewEngine embeds *sdk.Atol — all SDK methods (Check, Can, Authorize, GrantAccess, RevokeAccess, RegisterMaterializer, etc.) work directly on it. No control plane connection is needed; GrantAccess and RevokeAccess write to the local in-memory store.
// One at a time.
engine.Grant("user:alice", "editor", "document:doc-1")
engine.Revoke("user:alice", "editor", "document:doc-1")
// Bulk setup — replaces TestMain boilerplate.
engine.GrantAll([]atoltest.Tuple{
{User: "user:alice", Relation: "owner", Object: "org:acme"},
{User: "user:bob", Relation: "member", Object: "org:acme"},
{User: "user:charlie", Relation: "editor", Object: "document:doc-1"},
})
// Or use GrantAccess/RevokeAccess — they work locally, no control plane needed.
engine.GrantAccess(ctx, "user:alice", "owner", "document:doc-1")Build authenticated request contexts without hand-crafted helpers:
ctx := atoltest.Context().
WithUser("usr_123").
WithEmail("user@example.com").
WithOrg("acme").
WithRoles("admin", "editor").
WithPlan("pro").
WithMFA().
WithAuthMethod("passkey").
WithIdentity("oidc://acme.atol.sh/abc", "oidc").
Build()Use FromContext to enrich an existing context:
ctx := atoltest.FromContext(r.Context()).WithUser("alice").Build()Test tokens mint real RS256-signed JWTs that pass through the SDK's actual TokenValidator. The test engine runs a local JWKS server automatically.
func TestHTTPHandler(t *testing.T) {
engine := atoltest.NewEngine(t, atoltest.WithModelFile("testdata/model.yaml"))
engine.Grant("user:alice", "editor", "document:doc-123")
token := engine.Tokens().MintToken(
atoltest.WithSubject("alice"),
atoltest.WithEmail("user@example.com"),
atoltest.WithOrgID("acme"),
atoltest.WithRoles("admin"),
)
// Use the real SDK middleware — no bypass flags.
handler := atolmw.HTTPMiddleware(engine.Atol)(myHandler(engine.Atol))
r := httptest.NewRequest("GET", "/api/docs/doc-123", nil)
r.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}Token options: WithSubject, WithEmail, WithOrgID, WithRoles, WithPlan, WithAuthMethod, WithMFA, WithIdentity, WithTrustDomain, WithAudience, WithExpiry, WithJTI, WithAuthTime.
For handler tests that don't need real JWT validation, inject a principal directly:
handler := atoltest.AuthMiddleware("usr_test", "test@example.com",
atoltest.WithMiddlewareOrg("acme"),
atoltest.WithMiddlewareRoles("admin"),
)(myHandler)engine := atoltest.NewEngine(t,
atoltest.WithModel(model),
atoltest.WithTestAudience("https://api.example.com"),
)
// Tokens must include the matching audience.
token := engine.Tokens().MintToken(
atoltest.WithSubject("alice"),
atoltest.WithAudience("https://api.example.com"),
)When multiple engines need to trust the same JWKS (e.g., testing multi-service auth):
tf := atoltest.NewTokenFactory(t)
engine1 := atoltest.NewEngine(t, atoltest.WithModel(model), atoltest.WithTokenFactory(tf))
engine2 := atoltest.NewEngine(t, atoltest.WithModel(model), atoltest.WithTokenFactory(tf))
token := tf.MintToken(atoltest.WithSubject("shared-user"))
// Both engines accept this token.For browser-based E2E tests that need an HTTP token endpoint, use MockOIDCServer:
func TestMain(m *testing.M) {
srv := atoltest.NewMockOIDCServer(t)
// srv.URL() → "http://127.0.0.1:<port>"
// Endpoints:
// POST /oauth/token — mint a JWT
// GET /.well-known/jwks.json — public key set
// GET /.well-known/openid-configuration
// GET /health
}Or run the standalone server as a drop-in replacement for a mock-auth service:
go run atol.sh/sdk-go/cmd/atoltest-server -addr :3100Your Cypress tests can then fetch tokens via HTTP:
cy.request({
method: 'POST',
url: 'http://localhost:3100/oauth/token',
body: { sub: 'e2e-user', email: 'e2e@test.example.com' },
}).then((resp) => {
const token = resp.body.access_token;
// Use token in Authorization header
});| Option | Purpose |
|---|---|
WithModel(yaml) |
Load a Zanzibar model from bytes |
WithModelFile(path) |
Load a Zanzibar model from a file |
WithTuples(tuples...) |
Pre-populate the tuple store |
WithPolicy(bundle, data) |
Load an OPA policy bundle |
WithTestAudience(aud) |
Require JWT audience validation |
WithTokenFactory(tf) |
Share a JWKS across multiple engines |
- Go 1.26+
- Atol account (15-day free trial)
Apache 2.0