melange

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 8, 2026 License: MIT Imports: 8 Imported by: 0

README

Melange

Go Reference Go Report Card

Fine-grained authorization for PostgreSQL applications.

Melange brings Zanzibar-style relationship-based access control directly into your PostgreSQL database. Define authorization schemas using the OpenFGA DSL, and Melange runs permission checks as efficient SQL queries against your existing data.

Why Melange?

Traditional authorization systems require syncing your application data to a separate service. Melange takes a different approach: permissions are derived from views over your existing tables. This means:

  • Always in sync — No replication lag or eventual consistency
  • Transaction-aware — Permission checks see uncommitted changes
  • Zero runtime deps — Core library is pure Go stdlib
  • Single query — No recursive lookups at runtime

Inspired by OpenFGA and built on ideas from pgFGA.


📚 Full Documentation

Visit melange.pthm.dev for comprehensive guides, API reference, and examples.


Installation

CLI
go install github.com/pthm/melange/cmd/melange@latest
Library
# Core module (runtime, no external dependencies)
go get github.com/pthm/melange

# Tooling module (schema parsing, code generation)
go get github.com/pthm/melange/tooling

Quick Example

checker := melange.NewChecker(db)

// Check if user can read the repository
ok, err := checker.Check(ctx, user, "can_read", repo)
if !ok {
    return ErrForbidden
}

Contributing

Contributions are welcome! Here's how to get started:

  1. Fork the repository and clone locally
  2. Create a branch for your changes
  3. Run tests with go test ./...
  4. Submit a pull request with a clear description

Please ensure your code:

  • Passes all existing tests
  • Includes tests for new functionality
  • Follows the existing code style

For bug reports and feature requests, please open an issue.


Resources

  • Documentation — Guides, API reference, and examples
  • OpenFGA — The authorization model Melange implements
  • Zanzibar Paper — Google's original authorization system
  • pgFGA — PostgreSQL FGA implementation that inspired this project

License

MIT License — see LICENSE for details.

Documentation

Overview

Package melange provides PostgreSQL-based fine-grained authorization implementing OpenFGA/Zanzibar concepts with zero runtime dependencies.

Module Structure

Melange is split into two modules for clean dependency isolation:

  • github.com/pthm/melange (core): Runtime checker, types, errors. Stdlib only.
  • github.com/pthm/melange/tooling: Schema parsing, migration helpers. Depends on OpenFGA parser.

Most applications import only the core module at runtime. The tooling module is used during development (CLI, code generation) or for programmatic schema parsing.

Zero Tuple Sync

Melange uses a pure-PostgreSQL approach where permissions are derived from views over your existing application tables rather than maintaining separate tuple storage. You define a melange_tuples view over tables like users, repositories, etc. Permission checks query this view combined with the authorization model encoded in generated SQL functions.

Core Concepts

Objects represent typed resources. In FGA terms, both "users" and "resources" are objects - there's no special Subject type.

user := melange.Object{Type: "user", ID: "123"}
repo := melange.Object{Type: "repository", ID: "456"}

Basic Usage

checker := melange.NewChecker(db)
ok, err := checker.Check(ctx, user, "can_read", repo)

Transaction Support

The Checker works with *sql.DB, *sql.Tx, or *sql.Conn, enabling permission checks to see uncommitted changes within a transaction:

tx, _ := db.BeginTx(ctx, nil)
checker := melange.NewChecker(tx)
ok, _ := checker.Check(ctx, user, "can_write", repo)
// Permission check sees uncommitted transaction state

Caching

Use WithCache for repeated checks:

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

Decision Overrides

Use WithDecision for testing or admin tools:

checker := melange.NewChecker(db, melange.WithDecision(melange.DecisionAllow))

Schema Management

For schema parsing and migration, use the tooling module:

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

types, _ := tooling.ParseSchema("schemas/schema.fga")
err := tooling.Migrate(ctx, db, "schemas")

Index

Constants

View Source
const (
	// ErrorCodeValidation indicates an invalid request (bad relation, type, etc.).
	ErrorCodeValidation = 2000

	// ErrorCodeAuthorizationModelNotFound indicates the model doesn't exist.
	ErrorCodeAuthorizationModelNotFound = 2001

	// ErrorCodeResolutionTooComplex indicates depth/complexity exceeded.
	ErrorCodeResolutionTooComplex = 2002
)

OpenFGA error codes for compatibility with the OpenFGA API. These are used in ValidationError to provide OpenFGA-compatible error responses.

Variables

View Source
var (
	// ErrNoTuplesTable is returned when the melange_tuples relation doesn't exist.
	// This typically means the application hasn't created the view (or table/materialized view)
	// over its domain tables. See the melange documentation for view creation examples.
	ErrNoTuplesTable = errors.New("melange: melange_tuples view/table not found")

	// ErrInvalidSchema is returned when schema parsing fails.
	// Check the .fga file syntax using `fga model validate` from the OpenFGA CLI.
	ErrInvalidSchema = errors.New("melange: invalid schema")

	// ErrMissingFunction is returned when a required PostgreSQL function doesn't exist.
	// Run `melange migrate` to create the check_permission and list_accessible_* functions.
	ErrMissingFunction = errors.New("melange: authorization function missing")

	// ErrContextualTuplesUnsupported is returned when contextual tuples are used
	// with a Checker that cannot execute statements on a single connection.
	ErrContextualTuplesUnsupported = errors.New("melange: contextual tuples require *sql.DB, *sql.Tx, or *sql.Conn")

	// ErrInvalidContextualTuple is returned when contextual tuples fail validation.
	ErrInvalidContextualTuple = errors.New("melange: contextual tuple invalid")

	// ErrCyclicSchema is returned when the schema contains a cycle in the relation graph.
	// Cycles in implied-by or parent relations would cause infinite recursion at runtime.
	// Fix the schema by removing one of the relationships forming the cycle.
	ErrCyclicSchema = errors.New("melange: cyclic schema")
)

Sentinel errors for common failure modes during permission checks. These errors indicate setup issues, not permission denials. Permission checks return (false, nil) for denied access. These errors mean the authorization system cannot function due to missing schema components.

Use the Is*Err helper functions to check for specific errors and provide helpful setup messages to users.

Functions

func GetValidationErrorCode added in v0.2.0

func GetValidationErrorCode(err error) int

GetValidationErrorCode extracts the error code from a ValidationError. Returns 0 if err is not a ValidationError.

func IsCyclicSchemaErr added in v0.2.0

func IsCyclicSchemaErr(err error) bool

IsCyclicSchemaErr returns true if err is or wraps ErrCyclicSchema.

func IsInvalidSchemaErr

func IsInvalidSchemaErr(err error) bool

IsInvalidSchemaErr returns true if err is or wraps ErrInvalidSchema.

func IsMissingFunctionErr

func IsMissingFunctionErr(err error) bool

IsMissingFunctionErr returns true if err is or wraps ErrMissingFunction.

func IsNoTuplesTableErr

func IsNoTuplesTableErr(err error) bool

IsNoTuplesTableErr returns true if err is or wraps ErrNoTuplesTable.

func IsValidationError added in v0.2.0

func IsValidationError(err error) bool

IsValidationError returns true if err is or wraps a ValidationError.

func WithDecisionContext

func WithDecisionContext(ctx context.Context, decision Decision) context.Context

WithDecisionContext returns a new context with the given decision. This allows decision overrides to flow through context rather than requiring explicit Checker construction.

IMPORTANT: The Checker does NOT automatically consult this context value. Applications must opt-in via WithContextDecision() when creating the Checker. This prevents accidental authorization bypasses from middleware.

Prefer WithDecision option for explicit control. Use context-based decisions when the override needs to propagate through multiple layers where passing a Checker instance is impractical (e.g., testing frameworks, admin mode).

Types

type Cache

type Cache interface {
	// Get retrieves a cached permission check result.
	// Returns (allowed, err, found). If found is false, the entry doesn't exist or is expired.
	Get(subject Object, relation Relation, object Object) (allowed bool, err error, ok bool)

	// Set stores a permission check result in the cache.
	Set(subject Object, relation Relation, object Object, allowed bool, err error)
}

Cache stores permission check results. It is safe for concurrent use from multiple goroutines.

Implementations should cache both allowed and denied permissions, including errors. This reduces database load for repeated checks of denied access.

type CacheImpl

type CacheImpl struct {
	// contains filtered or unexported fields
}

CacheImpl is the default in-memory cache implementation with optional TTL. It uses a sync.RWMutex for goroutine safety. For high-contention scenarios, consider a sharded cache or external cache (Redis, etc.).

The cache grows unbounded within its TTL window. For long-running processes with large permission sets, consider periodic clearing or TTL-based expiry.

func NewCache

func NewCache(opts ...CacheOption) *CacheImpl

NewCache creates a new permission cache. The cache is safe for concurrent use but scoped to a single process. For distributed systems, implement Cache with a distributed store.

func (*CacheImpl) Clear

func (c *CacheImpl) Clear()

Clear removes all entries from the cache. Useful for testing or when permission data changes globally (e.g., after schema migration or mass permission updates).

func (*CacheImpl) Get

func (c *CacheImpl) Get(subject Object, relation Relation, object Object) (bool, error, bool)

Get retrieves a cached permission check result. Returns (allowed, err, found). If found is false, the entry doesn't exist or is expired.

func (*CacheImpl) Set

func (c *CacheImpl) Set(subject Object, relation Relation, object Object, allowed bool, err error)

Set stores a permission check result in the cache.

func (*CacheImpl) Size

func (c *CacheImpl) Size() int

Size returns the number of entries in the cache. Useful for monitoring cache growth and memory usage.

type CacheOption

type CacheOption func(*CacheImpl)

CacheOption configures a Cache.

func WithTTL

func WithTTL(ttl time.Duration) CacheOption

WithTTL sets the time-to-live for cache entries. Entries older than TTL are considered stale and will be re-checked. A TTL of 0 (default) means entries never expire within the cache's lifetime.

Choose TTL based on permission volatility:

  • Short TTL (seconds): Frequently changing permissions, high security
  • Medium TTL (minutes): Typical web applications
  • Long TTL or none: Near-static permissions, performance-critical paths

type Checker

type Checker struct {
	// contains filtered or unexported fields
}

Checker performs authorization checks against PostgreSQL. It evaluates permissions using generated SQL functions and the melange_tuples view (application data).

Checkers are lightweight and safe to create per-request. They hold no state beyond the database handle, cache, and decision override. The database handle can be *sql.DB, *sql.Tx, or *sql.Conn, allowing permission checks to see uncommitted changes within transactions.

Schema validation runs once per process on the first NewChecker call with a non-nil Querier. Validation issues are logged as warnings but do not prevent Checker creation, allowing applications to start even if the authorization schema is not yet fully configured.

func NewChecker

func NewChecker(q Querier, opts ...Option) *Checker

NewChecker creates a checker that works with *sql.DB, *sql.Tx, or *sql.Conn. Options allow callers to enable caching or decision overrides.

The Querier interface is satisfied by all three database handle types, enabling checkers to work seamlessly in transaction or connection-pooled contexts without requiring different APIs.

On the first call with a non-nil Querier, NewChecker validates the schema (once per process). Validation issues are logged as warnings but do not prevent Checker creation. This allows applications to start even if the authorization schema is not yet fully configured.

func (*Checker) Check

func (c *Checker) Check(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike) (bool, error)

Check returns true if subject has the relation on object. The check evaluates direct tuples, implied relations (role hierarchies), and parent inheritance according to the loaded FGA schema.

Example:

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

If a cache is configured via WithCache, results are cached by the tuple (subject, relation, object). The cache stores both successful and failed checks, including errors. This prevents repeated database queries for denied permissions or missing objects.

If a decision override is set via WithDecision, the database is not queried. If WithContextDecision is enabled, context decisions are also consulted.

func (*Checker) CheckWithContextualTuples added in v0.2.0

func (c *Checker) CheckWithContextualTuples(
	ctx context.Context,
	subject SubjectLike,
	relation RelationLike,
	object ObjectLike,
	tuples []ContextualTuple,
) (bool, error)

CheckWithContextualTuples returns true if subject has the relation on object, using the provided contextual tuples for this call only. Contextual tuples are validated against the loaded model before evaluation.

func (*Checker) ListObjects

func (c *Checker) ListObjects(ctx context.Context, subject SubjectLike, relation RelationLike, objectType ObjectType) ([]string, error)

ListObjects returns all object IDs of the given type that subject has relation on.

Example:

ids, _ := checker.ListObjects(ctx, authz.User("123"), authz.RelCanRead, authz.TypeRepository)

Note: This method does NOT use the permission cache because it returns a list rather than a single boolean result.

Note on decision overrides:

  • DecisionDeny: returns empty list (no access)
  • DecisionAllow: falls through to normal check (can't enumerate "all" objects)

Uses a recursive CTE to walk the permission graph in a single query, providing 10-50x improvement over N+1 patterns on large datasets.

func (*Checker) ListObjectsWithContextualTuples added in v0.2.0

func (c *Checker) ListObjectsWithContextualTuples(
	ctx context.Context,
	subject SubjectLike,
	relation RelationLike,
	objectType ObjectType,
	tuples []ContextualTuple,
) ([]string, error)

ListObjectsWithContextualTuples returns object IDs for a subject using contextual tuples. Contextual tuples are validated against the loaded model before evaluation.

func (*Checker) ListSubjects

func (c *Checker) ListSubjects(ctx context.Context, object ObjectLike, relation RelationLike, subjectType ObjectType) ([]string, error)

ListSubjects returns all subject IDs of the given type that have relation on object. This is the inverse of ListObjects - it answers "who has access to this object?"

Example:

ids, _ := checker.ListSubjects(ctx, authz.Repository("456"), authz.RelCanRead, authz.TypeUser)
// Returns IDs of all users who can read repository 456

Note: This method does NOT use the permission cache because it returns a list rather than a single boolean result.

Note on decision overrides:

  • DecisionDeny: returns empty list (no subjects have access)
  • DecisionAllow: falls through to normal check (can't enumerate "all" subjects)

Uses a recursive CTE to walk the permission graph in a single query, providing 10-50x improvement over N+1 patterns on large datasets.

func (*Checker) ListSubjectsWithContextualTuples added in v0.2.0

func (c *Checker) ListSubjectsWithContextualTuples(
	ctx context.Context,
	object ObjectLike,
	relation RelationLike,
	subjectType ObjectType,
	tuples []ContextualTuple,
) ([]string, error)

ListSubjectsWithContextualTuples returns subject IDs for an object using contextual tuples. Contextual tuples are validated against the loaded model before evaluation.

func (*Checker) Must

func (c *Checker) Must(ctx context.Context, subject SubjectLike, relation RelationLike, object ObjectLike)

Must panics if the permission check fails or errors. Use in handlers after authentication has already verified the user exists.

This is useful for enforcing permissions in code paths where denial should be a programmer error rather than a user-facing error. For example:

// After RequireAuth middleware ensures user is authenticated:
repo := getRepository(...)
checker.Must(ctx, authz.User(user.ID), authz.RelCanWrite, repo)
// Only reachable if permission granted

Prefer Check() for user-facing authorization where you need to return a 403 Forbidden response. Use Must() for internal invariants where unauthorized access indicates a bug in the calling code.

type ContextualTuple added in v0.2.0

type ContextualTuple struct {
	Subject  Object
	Relation Relation
	Object   Object
}

ContextualTuple represents a tuple provided at request time. These tuples are not persisted and only affect a single check/list call.

type Decision

type Decision int

Decision allows bypassing DB checks for admin tools and tests. Decisions provide explicit control over authorization behavior without modifying the underlying permission model or tuple data.

The decision mechanism has two layers:

  1. Checker-level: Set via WithDecision() at Checker construction
  2. Context-level: Set via WithDecisionContext() and opt-in via WithContextDecision()

Context-based decisions are opt-in by design. Applications must explicitly enable WithContextDecision() when creating the Checker to prevent accidental authorization bypasses from propagating through middleware. This makes the security boundary explicit: "this Checker respects context overrides."

const (
	// DecisionUnset means no override - perform normal permission check.
	DecisionUnset Decision = iota

	// DecisionAllow bypasses checks and always returns true (allowed).
	// Use for admin tools, background jobs, or testing authorized code paths.
	DecisionAllow

	// DecisionDeny bypasses checks and always returns false (denied).
	// Use for testing unauthorized code paths without database setup.
	DecisionDeny
)

func GetDecisionContext

func GetDecisionContext(ctx context.Context) Decision

GetDecisionContext retrieves the decision from context. Returns DecisionUnset if no decision is set.

Applications can use this to check for decision overrides before creating a Checker, enabling context-based bypass patterns.

type Execer

type Execer interface {
	Querier
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

Execer extends Querier with ExecContext for migrations. Only required by the CLI migrate command, not for runtime permission checks. Separating this interface keeps the Checker dependency minimal.

type Object

type Object struct {
	Type ObjectType
	ID   string
}

Object represents a typed resource identifier. In FGA terms, both "users" and "resources" are objects - there's no distinction between subjects and objects at the type level.

Objects are value types and safe to copy. The canonical string format is "type:id", used in logging and debugging.

func (Object) FGAObject

func (o Object) FGAObject() Object

FGAObject returns the object itself, implementing ObjectLike. This allows Object to be used directly in permission checks.

func (Object) FGASubject

func (o Object) FGASubject() Object

FGASubject returns the object itself, implementing SubjectLike. In FGA terms, subjects are also objects - this allows Object to be used as either the subject or object in permission checks.

func (Object) String

func (o Object) String() string

String returns the canonical representation "type:id".

type ObjectLike

type ObjectLike interface {
	FGAObject() Object
}

ObjectLike defines an interface for types that can be converted to Objects. This allows domain models to implement authorization-aware methods without importing the full domain layer into melange.

Example:

type Repository struct { ID int64; OwnerName string }
func (r Repository) FGAObject() melange.Object {
    return melange.Object{Type: "repository", ID: fmt.Sprint(r.ID)}
}

The Checker accepts ObjectLike rather than Object directly, enabling type-safe authorization checks against domain models.

type ObjectType

type ObjectType string

ObjectType represents the type of an object.

func (ObjectType) String

func (ot ObjectType) String() string

String returns the string representation of the object type.

type Option

type Option func(*Checker)

Option configures a Checker.

func WithCache

func WithCache(c Cache) Option

WithCache enables caching for permission check results. Caching is safe across goroutines but scoped to a single Checker instance. For request-scoped caching, create a new Checker per request with a request-scoped cache.

func WithContextDecision

func WithContextDecision() Option

WithContextDecision enables context-based decision overrides. When enabled, Check will consult GetDecisionContext(ctx) before performing database checks. This allows authorization decisions to propagate through middleware layers.

Decision precedence when enabled:

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

By default, context decisions are NOT consulted. This opt-in design ensures explicit control over when context can override authorization.

func WithDecision

func WithDecision(d Decision) Option

WithDecision sets a decision override that bypasses database checks. Use DecisionAllow for admin tools or testing authorized paths. Use DecisionDeny for testing unauthorized paths. This is intentionally separate from context-based overrides to make the bypass explicit at Checker construction time.

func WithRequestValidation added in v0.2.0

func WithRequestValidation() Option

WithRequestValidation enables validation of check requests before SQL execution. When a Validator is provided, this validates that the object type, relation, and subject type exist in the model.

func WithUsersetValidation added in v0.2.0

func WithUsersetValidation() Option

WithUsersetValidation enables validation for userset subjects like "group:1#member". When a Validator is provided, Check returns an error if the userset type or relation is not defined in the authorization model.

func WithValidator added in v0.3.0

func WithValidator(v Validator) Option

WithValidator supplies a schema-aware validator for request validation.

type Querier

type Querier interface {
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

Querier executes queries against PostgreSQL. Implemented by *sql.DB, *sql.Tx, and *sql.Conn.

The minimal interface allows Checker to work in transaction contexts without requiring a full database connection. This enables permission checks to see uncommitted changes within a transaction, supporting patterns like:

tx.Exec("INSERT INTO repositories ...")
// melange_tuples view reflects the new row
checker.Check(ctx, user, "can_read", repo) // sees new tuple
tx.Commit()

type Relation

type Relation string

Relation represents a typed relation identifier. Relations can be permissions (can_read, can_write) or roles (owner, member). Unlike some authorization systems, melange treats all relations uniformly.

func (Relation) FGARelation

func (r Relation) FGARelation() Relation

FGARelation returns the relation itself, implementing RelationLike.

func (Relation) String

func (r Relation) String() string

String returns the canonical representation of the relation.

type RelationLike

type RelationLike interface {
	FGARelation() Relation
}

RelationLike defines an interface for types that can be converted to Relations. This allows generated code to provide type-safe relation constants while accepting custom relation types from domain models.

type SubjectLike

type SubjectLike interface {
	FGASubject() Object
}

SubjectLike defines an interface for types that can be used as subjects. In FGA terms, subjects are the "who" in "who has what relation on what object". Subjects are typically users but can be any typed resource.

Example:

type User struct { ID int64; Username string }
func (u User) FGASubject() melange.Object {
    return melange.Object{Type: "user", ID: fmt.Sprint(u.ID)}
}

Note: Object implements both SubjectLike and ObjectLike, allowing melange.Object values to be used directly in either position.

type ValidationError added in v0.2.0

type ValidationError struct {
	// Code is the OpenFGA error code (e.g., 2000 for validation errors).
	Code int

	// Message describes the validation failure.
	Message string
}

ValidationError represents an OpenFGA-compatible validation error. It contains an error code and message matching OpenFGA's error semantics.

func (*ValidationError) Error added in v0.2.0

func (e *ValidationError) Error() string

Error implements the error interface.

func (*ValidationError) ErrorCode added in v0.2.0

func (e *ValidationError) ErrorCode() int

ErrorCode returns the OpenFGA error code.

type Validator added in v0.3.0

type Validator interface {
	ValidateUsersetSubject(subject Object) error
	ValidateCheckRequest(subject Object, relation Relation, object Object) error
	ValidateListUsersRequest(relation Relation, object Object, subjectType ObjectType) error
	ValidateContextualTuple(tuple ContextualTuple) error
}

Validator provides schema-aware request validation without relying on database-backed model tables.

Directories

Path Synopsis
cmd
melange module
melange module
Package schema provides OpenFGA schema types and transformation logic for melange.
Package schema provides OpenFGA schema types and transformation logic for melange.
tooling module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL