Awesome Go

canery

CategorySecurity
SubcategorySecurity
Stars8

Minimal, stateless authorization engine with a pluggable evaluation model

About canery

CI Go Go Reference Go Report Card GitHub release codecov License stdlib-only

🧠 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:

  • MembershipReader
  • GroupReader
  • PermissionReader
  • ResourceScopeResolver
  • BatchPermissionReader (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

  • Engine and PolicyAuthorizer are immutable after construction and safe to share across goroutines.
  • Builder and MultiActionBuilder are value types; every method returns a copy, so they are safe to reuse and branch.
  • BatchCheck and multi-action checks run sequentially; canery does not call readers concurrently on its own.
  • Request, Decision, and Trace are 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

πŸ“‚ Examples

  • examples/simple β†’ minimal core usage
  • examples/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.idnon-blank
actionnon-blank
resource.typenon-blank (ID optional: empty means a type-level check)
scope.type, scope.idnon-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.

← Back to Security