Skip to content
Checking Permissions

Checking Permissions

The permission check API evaluates whether a subject has a specific relation on an object. This is the core operation for authorization decisions.

Basic Permission Check

import "github.com/pthm/melange/melange"

// Create a checker
checker := melange.NewChecker(db)

// Define subject and object
user := melange.Object{Type: "user", ID: "123"}
repo := melange.Object{Type: "repository", ID: "456"}

// Check permission
allowed, err := checker.Check(ctx, user, "can_read", repo)
if err != nil {
    return err
}
if !allowed {
    return ErrForbidden
}

The Checker accepts any type implementing the Querier interface:

  • *sql.DB - Connection pool
  • *sql.Tx - Transaction (sees uncommitted changes)
  • *sql.Conn - Single connection

Type-Safe Interfaces

Use generated code or implement type-safe interfaces for cleaner code:

Implement SubjectLike and ObjectLike on your domain models:

type User struct {
    ID   int64
    Name string
}

func (u User) FGASubject() melange.Object {
    return melange.Object{Type: "user", ID: fmt.Sprint(u.ID)}
}

type Repository struct {
    ID   int64
    Name string
}

func (r Repository) FGAObject() melange.Object {
    return melange.Object{Type: "repository", ID: fmt.Sprint(r.ID)}
}

// Now use directly in checks
allowed, err := checker.Check(ctx, user, "can_read", repo)

With generated code from melange generate client:

import "myapp/internal/authz"

allowed, err := checker.Check(ctx,
    authz.User("123"),
    authz.RelCanRead,
    authz.Repository("456"),
)

Caching

Enable caching to avoid repeated database queries for the same permission check. See the Caching guide for full details on TTL configuration, request-scoped caching, custom cache implementations, and cache invalidation strategies.

cache := melange.NewCache(melange.WithTTL(time.Minute))
checker := melange.NewChecker(db, melange.WithCache(cache))

Decision Overrides

Bypass database checks for testing or admin tools:

// Always allow - for admin tools or testing authorized paths
checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionAllow))

// Always deny - for testing unauthorized paths
checker := melange.NewChecker(nil, melange.WithDecision(melange.DecisionDeny))

When a decision override is set, no database query is performed.

Context-Based Overrides

Enable context-based decision overrides for request-scoped behavior:

checker := melange.NewChecker(db, melange.WithContextDecision())

// In middleware or handler:
ctx := melange.WithDecisionContext(ctx, melange.DecisionAllow)

// Check uses context decision, no database query
allowed, _ := checker.Check(ctx, user, "can_read", repo)

Decision precedence (when WithContextDecision is enabled):

  1. Context decision (via WithDecisionContext)
  2. Checker decision (via WithDecision)
  3. Database check

Transaction Support

Permission checks work within transactions and see uncommitted changes:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

// Insert new data within transaction
_, err = tx.ExecContext(ctx, `
    INSERT INTO organization_members (user_id, organization_id, role)
    VALUES ($1, $2, 'member')
`, userID, orgID)
if err != nil {
    return err
}

// Checker on transaction sees the uncommitted row
checker := melange.NewChecker(tx)
allowed, err := checker.Check(ctx, user, "member", org)
// allowed == true, even before commit

if err := tx.Commit(); err != nil {
    return err
}

Error Handling

Sentinel Errors

import "github.com/pthm/melange/melange"

var (
    melange.ErrNoTuplesTable   // melange_tuples view doesn't exist
    melange.ErrMissingFunction // SQL functions not installed
)

Error Checkers

allowed, err := checker.Check(ctx, user, "can_read", repo)
if err != nil {
    if melange.IsNoTuplesTableErr(err) {
        // melange_tuples view needs to be created
        log.Error("Authorization not configured: missing melange_tuples view")
    } else if melange.IsMissingFunctionErr(err) {
        // Run melange migrate
        log.Error("Authorization not configured: run 'melange migrate'")
    }
    return err
}

Must - Panic on Failure

Use Must for internal invariants where unauthorized access is a programmer error:

// Panic if check fails or errors
checker.Must(ctx, user, "can_write", repo)

// Only reachable if permission granted

Prefer Check for user-facing authorization. Use Must when:

  • Access denial indicates a bug (not a user error)
  • You’ve already validated access at a higher level
  • In tests where failure should panic

Performance Tips

1. Use Request-Scoped Caching

Create a cache per request to avoid stale data across requests:

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cache := melange.NewCache() // Fresh cache per request
        checker := melange.NewChecker(db, melange.WithCache(cache))
        ctx := context.WithValue(r.Context(), "checker", checker)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

2. Batch Checks with Bulk API

Use NewBulkCheck to check many permissions in a single SQL call instead of looping over individual checks:

bulk := checker.NewBulkCheck(ctx)

// Queue checks - all execute in one SQL call
for _, repo := range repos {
    bulk.Add(user, "can_read", repo)
}

// Or use AddMany for one subject+relation across multiple objects
bulk.AddMany(user, "can_read", repos...)

results, err := bulk.Execute()
if err != nil {
    return err
}

// Check aggregate results
if results.All() {
    // Every check was allowed
}
if results.Any() {
    // At least one check was allowed
}

// Iterate individual results
for _, r := range results.Allowed() {
    fmt.Printf("%s:%s is accessible\n", r.Object().Type, r.Object().ID)
}

// Use AllOrError for guard-style checks
if err := results.AllOrError(); err != nil {
    return err // Returns BulkCheckDeniedError with details
}

Use AddWithID to tag checks with meaningful identifiers:

bulk := checker.NewBulkCheck(ctx)
bulk.AddWithID("read-repo", user, "can_read", repo)
bulk.AddWithID("write-repo", user, "can_write", repo)
bulk.AddWithID("admin-repo", user, "admin", repo)

results, err := bulk.Execute()
if err != nil {
    return err
}

// Look up results by ID
if r := results.GetByID("write-repo"); r != nil && r.IsAllowed() {
    // User can write
}
Deduplication: Duplicate checks within a batch are automatically deduplicated. Only unique permission tuples are sent to the database; results are fanned out to all original positions.
Size limit: A single bulk check supports up to 10,000 checks (MaxBulkCheckSize in Go, MAX_BULK_CHECK_SIZE in TypeScript). Exceeding this limit returns an error.

3. Use ListObjects for Filtering

Instead of checking each object individually, use list_accessible_objects:

// Inefficient: N database queries
for _, repo := range repos {
    if allowed, _ := checker.Check(ctx, user, "can_read", repo); allowed {
        // ...
    }
}

// Efficient: 1 database query
accessibleIDs, _ := checker.ListObjects(ctx, user, "can_read", "repository")
idSet := make(map[string]bool)
for _, id := range accessibleIDs {
    idSet[id] = true
}
for _, repo := range repos {
    if idSet[repo.ID] {
        // ...
    }
}

See Listing Objects for details.

Schema Validation

On first Checker creation, Melange validates the database schema (once per process). Issues are logged as warnings:

[melange] WARNING: check_permission function not found. Run 'melange migrate' to create it.

These warnings don’t prevent Checker creation, allowing applications to start before authorization is fully configured.