zoobzio January 24, 2026 Edit this page

Guards

Guards are the core access control mechanism in slush. This guide covers writing, composing, and testing guards.

Guard Signature

A guard is a function that validates context:

type Guard func(ctx context.Context) error
  • Return nil to allow access
  • Return an error to deny access (wrapped with ErrAccessDenied)

Basic Guards

Static guard

Always allows or denies:

func allowAll(ctx context.Context) error {
    return nil
}

func denyAll(ctx context.Context) error {
    return errors.New("access denied")
}

Context-checking guard

Validates values in context:

func requireAuth(ctx context.Context) error {
    if auth.UserFromContext(ctx) == nil {
        return errors.New("unauthenticated")
    }
    return nil
}

Parameterized Guards

Guards that take configuration return a Guard:

func requireRole(role string) slush.Guard {
    return func(ctx context.Context) error {
        user := auth.UserFromContext(ctx)
        if user == nil {
            return errors.New("unauthenticated")
        }
        if user.Role != role {
            return fmt.Errorf("requires role: %s", role)
        }
        return nil
    }
}

// Usage
slush.Register[AdminAPI](key, api).
    Guard(requireRole("admin"))

Multiple parameters

func requirePermissions(perms ...string) slush.Guard {
    return func(ctx context.Context) error {
        user := auth.UserFromContext(ctx)
        if user == nil {
            return errors.New("unauthenticated")
        }
        for _, perm := range perms {
            if !user.HasPermission(perm) {
                return fmt.Errorf("missing permission: %s", perm)
            }
        }
        return nil
    }
}

slush.Register[Database](key, db).
    Guard(requirePermissions("db:read", "db:write"))

Composing Guards

Chaining

Guards chain via .Guard():

slush.Register[Database](key, db).
    Guard(requireAuth).
    Guard(requireRole("service")).
    Guard(requirePermission("db:access"))

Guards run in order; first error stops the chain.

AND composition

All guards must pass:

func and(guards ...slush.Guard) slush.Guard {
    return func(ctx context.Context) error {
        for _, g := range guards {
            if err := g(ctx); err != nil {
                return err
            }
        }
        return nil
    }
}

slush.Register[Database](key, db).
    Guard(and(requireAuth, requireRole("admin")))

OR composition

At least one guard must pass:

func or(guards ...slush.Guard) slush.Guard {
    return func(ctx context.Context) error {
        var lastErr error
        for _, g := range guards {
            if err := g(ctx); err == nil {
                return nil
            } else {
                lastErr = err
            }
        }
        return lastErr
    }
}

// Allow admins OR the resource owner
slush.Register[Resource](key, r).
    Guard(or(requireRole("admin"), requireOwner))

NOT composition

Invert a guard:

func not(g slush.Guard) slush.Guard {
    return func(ctx context.Context) error {
        if err := g(ctx); err != nil {
            return nil  // Original failed, so NOT passes
        }
        return errors.New("guard passed when it should fail")
    }
}

Stateful Guards

Guards can maintain state across invocations:

Rate limiting

func rateLimit(perSecond int) slush.Guard {
    limiter := rate.NewLimiter(rate.Limit(perSecond), perSecond)
    return func(ctx context.Context) error {
        if !limiter.Allow() {
            return errors.New("rate limited")
        }
        return nil
    }
}

slush.Register[ExternalAPI](key, api).
    Guard(rateLimit(100))

Circuit breaker

func circuitBreaker(threshold int, resetAfter time.Duration) slush.Guard {
    var (
        mu       sync.Mutex
        failures int
        openedAt time.Time
    )

    return func(ctx context.Context) error {
        mu.Lock()
        defer mu.Unlock()

        // Check if circuit is open
        if failures >= threshold {
            if time.Since(openedAt) < resetAfter {
                return errors.New("circuit open")
            }
            // Reset after cooldown
            failures = 0
        }
        return nil
    }
}

Async-Safe Guards

Guards may be called concurrently. Ensure thread safety:

func countingGuard() (slush.Guard, func() int) {
    var count atomic.Int64

    guard := func(ctx context.Context) error {
        count.Add(1)
        return nil
    }

    getCount := func() int {
        return int(count.Load())
    }

    return guard, getCount
}

Error Messages

Provide clear, actionable error messages:

// Bad
return errors.New("denied")

// Good
return errors.New("missing permission: db:write")

// Better (with context)
return fmt.Errorf("user %s lacks permission: db:write", user.ID)

Error messages appear in:

  • The wrapped error returned by Use
  • capitan events (KeyError field)
  • Logs if you're logging access denials

Testing Guards

Guards are plain functions—test them directly:

func TestRequireAuth(t *testing.T) {
    // Without user
    err := requireAuth(context.Background())
    if err == nil {
        t.Error("expected error without user")
    }

    // With user
    ctx := auth.WithUser(context.Background(), &User{ID: 1})
    err = requireAuth(ctx)
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

func TestRequireRole(t *testing.T) {
    guard := requireRole("admin")

    tests := []struct {
        name    string
        user    *User
        wantErr bool
    }{
        {"no user", nil, true},
        {"wrong role", &User{Role: "user"}, true},
        {"correct role", &User{Role: "admin"}, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ctx := context.Background()
            if tt.user != nil {
                ctx = auth.WithUser(ctx, tt.user)
            }
            err := guard(ctx)
            if (err != nil) != tt.wantErr {
                t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
            }
        })
    }
}

Next Steps