canery
Minimal, stateless authorization engine with a pluggable evaluation model
About canery
π§ What is canery?
canery is a minimal, generic authorization kernel built around a simple and explicit model:
- subject
- action
- resource
- scope
It focuses purely on authorization evaluation, not storage or product semantics.
It answers: βCan X do Y on Z within W?β
canery evaluates authorization; it does not own the systems that provide identity, resources, or authorization data. See docs/architecture.md.
β‘ Why use it?
Most authorization solutions are either:
- too opinionated (roles, orgs, workspaces baked in)
- too heavy (full IAM platforms)
canery stays in the middle:
- β explicit and predictable request model
- β no framework or product lock-in
- β pluggable data sources
- β small, composable core
- β easy to test
π« What it does NOT do
- β does not store permissions
- β does not define roles or org models
- β does not include a database layer
- β does not enforce product semantics
You bring those via adapters.
π₯ Install
go get github.com/rluders/canery/v2
import "github.com/rluders/canery/v2" // package canery
π Quick Example
ok, err := authorizer.Check(ctx, canery.Request{
Subject: canery.Actor("user", userID),
Action: canery.Action("delete"),
Resource: canery.Resource("document", documentID),
Scope: canery.Scope("project", projectID),
})
π Decision Details
decision, err := authorizer.CheckDecision(ctx, canery.Request{
Subject: canery.Actor("user", userID),
Action: canery.Action("delete"),
Resource: canery.Resource("document", documentID),
Scope: canery.Scope("project", projectID),
})
if decision.Allowed {
fmt.Println(decision.Source) // canery.DecisionSourceDirect or canery.DecisionSourceGroup
principal, _ := decision.EffectivePrincipal()
fmt.Println(principal) // the subject, or the first matching group
} else {
fmt.Println(decision.Source) // "none"
fmt.Println(decision.Reason) // e.g. "no matching permission"
}
π§± Fluent API
ok, err := authorizer.
For(canery.Actor("user", userID)).
Can(canery.Action("delete")).
Target(canery.Resource("document", documentID)).
In(canery.Scope("project", projectID)).
Check(ctx)
π Multiple Actions
result, err := authorizer.
For(canery.Actor("user", userID)).
CanMany(
canery.Action("view"),
canery.Action("update"),
canery.Action("delete"),
).
Target(canery.Resource("document", documentID)).
In(canery.Scope("project", projectID)).
Check(ctx)
canUpdate, _ := result.Allowed(canery.Action("update"))
π¦ Batch Checks
Batches are evaluated together: identical membership, group, resource-scope, and permission lookups run once, and permission readers that implement the optional BatchPermissionReader answer many checks in one call.
decisions, err := engine.BatchCheck(ctx, []canery.Request{
{
Subject: canery.Actor("user", userID),
Action: canery.Action("view"),
Resource: canery.Resource("document", documentID),
Scope: canery.Scope("project", projectID),
},
{
Subject: canery.Actor("user", userID),
Action: canery.Action("delete"),
Resource: canery.Resource("document", documentID),
Scope: canery.Scope("project", projectID),
},
})
// All-or-nothing: on failure, err is a canery.BatchCheckError for the lowest failing index.
// Keep partial results instead:
for _, result := range engine.BatchCheckResults(ctx, requests) {
if result.Err != nil { /* could not be evaluated */ continue }
fmt.Println(result.Decision.Allowed)
}
See docs/batch.md for the full failure contract.
π§ͺ Debugging (Trace)
decision, trace, err := authorizer.CheckTrace(ctx, canery.Request{
Subject: canery.Actor("user", userID),
Action: canery.Action("delete"),
Resource: canery.Resource("document", documentID),
Scope: canery.Scope("project", projectID),
})
for _, step := range trace.Steps {
fmt.Println(step.Name, step.Result, step.Principal, step.Source)
}
_ = decision
Traces never include context values or reader error details. Reader failures surface as canery.EvaluationError (inspect the cause with errors.Is / errors.As). See docs/diagnostics.md.
βοΈ Default Evaluation Strategy
The default Engine uses a membership-first evaluation flow:
flowchart TD
A[Check request] --> B[Validate request]
B -->|invalid| X[Return validation error]
B --> C{Resource ID present?}
C -- yes --> D[Verify resource belongs to scope]
D -- no --> L[Deny]
C -- no --> E[Verify subject belongs to scope]
D --> E
E -- no --> L
E --> F[Check direct subject permission]
F --> G{Allowed?}
G -- yes --> H[Allow]
G -- no --> I[Resolve groups in scope]
I --> J[Check group permissions]
J --> K{Any allowed?}
K -- yes --> H
K -- no --> L[Deny]
This is just the default engine β other evaluation strategies can be implemented.
π§© Architecture
flowchart LR
App["Application"] -->|"For(subject)...Check(ctx)"| B["Builder"]
App -->|"Check(ctx, Request)"| A
B -->|"builds Request"| A
subgraph Authorizers["Authorizer implementations"]
A{{"Authorizer interface"}}
PA["PolicyAuthorizer"]
E["Engine"]
end
A -.->|"implemented by"| PA
A -.->|"implemented by"| E
PA -->|"Policies, then next"| E
subgraph Readers["Readers (your adapters)"]
M["MembershipReader"]
G["GroupReader"]
PR["PermissionReader / BatchPermissionReader"]
S["ResourceScopeResolver"]
end
E --> M
E --> G
E --> PR
E --> S
π Extensibility
All data access is delegated to interfaces:
MembershipReaderGroupReaderPermissionReaderResourceScopeResolverBatchPermissionReader(optional)
This keeps the core:
- stateless
- storage-agnostic
- easy to test
π§ Policies (Optional)
authorizer := canery.NewPolicyAuthorizer(
baseAuthorizer,
canery.ForResourceType("document", canery.PolicyFunc(func(ctx context.Context, request canery.Request, next canery.DecisionEvaluator) (canery.Decision, error) {
if request.Action == canery.Action("archive") {
return canery.Decision{
Allowed: false,
Reason: "policy matched",
Source: canery.DecisionSourceNone,
}, nil
}
return next.CheckDecision(ctx, request)
})),
)
Policies are:
- middleware: delegate, short-circuit, or transform decisions
- matcher-based and run in configured order
- optional
There is no built-in deny/allow precedence. See docs/policies.md.
Authorization context
Policies can read application-defined values without framework types:
type authMethodKey struct{}
ok, err := authorizer.
For(canery.Actor("user", userID)).
Can(canery.Action("delete")).
Target(canery.Resource("document", documentID)).
In(canery.Scope("project", projectID)).
WithValue(authMethodKey{}, "mfa").
Check(ctx)
// inside a Policy:
method, _ := canery.ContextValue[string](request.Context, authMethodKey{})
The default engine never interprets context values, and they never appear in decisions, traces, or errors.
π§± Adapter Pattern (Recommended)
Keep your application semantics outside the core:
package projectauthz
import "github.com/rluders/canery/v2"
const EditDocument = canery.Action("edit")
func User(id string) canery.Subject {
return canery.Actor("user", id)
}
func ProjectScope(id string) canery.ScopeRef {
return canery.Scope("project", id)
}
func Document(id string) canery.ResourceRef {
return canery.Resource("document", id)
}
Usage:
ok, err := authorizer.
For(projectauthz.User(userID)).
Can(projectauthz.EditDocument).
Target(projectauthz.Document(documentID)).
In(projectauthz.ProjectScope(projectID)).
Check(ctx)
π Concurrency
EngineandPolicyAuthorizerare immutable after construction and safe to share across goroutines.BuilderandMultiActionBuilderare value types; every method returns a copy, so they are safe to reuse and branch.BatchCheckand multi-action checks run sequentially; canery does not call readers concurrently on its own.Request,Decision, andTraceare plain values owned by the caller; canery never retains or mutates them after returning.- Readers, resolvers, wrapped authorizers, and Policies must be concurrency-safe themselves when shared. canery does not serialize access to them. Policies should be stateless and must not assume sequential or goroutine-affine invocation.
π Documentation
- Authorization model & invariants
- Architecture Β· Policies Β· Batch
- Diagnostics Β· Adapters & performance
- Architecture Decision Records Β· Migrating to v2
π Examples
examples/simpleβ minimal core usageexamples/advancedβ realistic app setup (users, projects, roles, etc.)
Each example is a standalone Go module.
β οΈ Validation Rules
Checked in this order; the first failure is returned:
Path() | Rule |
|---|---|
subject.type, subject.id | non-blank |
action | non-blank |
resource.type | non-blank (ID optional: empty means a type-level check) |
scope.type, scope.id | non-blank |
Invalid requests return a ValidationError{Field, Property, Code}
(errors.Is(err, canery.ErrInvalidRequest) works from every entry point).
π― When to use canery
Use it when you want:
- explicit, testable authorization logic
- full control over your data model
- no framework constraints
Avoid it if you need:
- a complete IAM system
- UI, policy language, or managed service
- out-of-the-box RBAC/ABAC models
π License
MIT
Frequently Asked Questions
What is canery?
canery is a Security library for the Go programming language. Minimal, stateless authorization engine with a pluggable evaluation model
How do I install canery?
Install canery with the Go module system using `go get rluders/canery`. Check the repository for the current installation instructions.
What category does canery belong to?
canery is listed under Security, specifically Security.