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):
- Context decision (via
WithDecisionContext) - Checker decision (via
WithDecision) - 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 grantedPrefer 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
}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.