Skip to content
rludersPublic

About

A small, flexible authorization core for Go, built around subject–action–resource–scope checks.

Resources

Stars

8 stars

Watchers

1 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

canery logo

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]
Loading

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
Loading

🔌 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.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

About

A small, flexible authorization core for Go, built around subject–action–resource–scope checks.

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Contributors

Languages