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
- Variables
- func GetValidationErrorCode(err error) int
- func IsCyclicSchemaErr(err error) bool
- func IsInvalidSchemaErr(err error) bool
- func IsMissingFunctionErr(err error) bool
- func IsNoTuplesTableErr(err error) bool
- func IsValidationError(err error) bool
- func WithDecisionContext(ctx context.Context, decision Decision) context.Context
- type Cache
- type CacheImpl
- type CacheOption
- type Checker
- func (c *Checker) Check(ctx context.Context, subject SubjectLike, relation RelationLike, ...) (bool, error)
- func (c *Checker) CheckWithContextualTuples(ctx context.Context, subject SubjectLike, relation RelationLike, ...) (bool, error)
- func (c *Checker) ListObjects(ctx context.Context, subject SubjectLike, relation RelationLike, ...) ([]string, error)
- func (c *Checker) ListObjectsWithContextualTuples(ctx context.Context, subject SubjectLike, relation RelationLike, ...) ([]string, error)
- func (c *Checker) ListSubjects(ctx context.Context, object ObjectLike, relation RelationLike, ...) ([]string, error)
- func (c *Checker) ListSubjectsWithContextualTuples(ctx context.Context, object ObjectLike, relation RelationLike, ...) ([]string, error)
- func (c *Checker) Must(ctx context.Context, subject SubjectLike, relation RelationLike, ...)
- type ContextualTuple
- type Decision
- type Execer
- type Object
- type ObjectLike
- type ObjectType
- type Option
- type Querier
- type Relation
- type RelationLike
- type SubjectLike
- type ValidationError
- type Validator
Constants ¶
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 ¶
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
GetValidationErrorCode extracts the error code from a ValidationError. Returns 0 if err is not a ValidationError.
func IsCyclicSchemaErr ¶ added in v0.2.0
IsCyclicSchemaErr returns true if err is or wraps ErrCyclicSchema.
func IsInvalidSchemaErr ¶
IsInvalidSchemaErr returns true if err is or wraps ErrInvalidSchema.
func IsMissingFunctionErr ¶
IsMissingFunctionErr returns true if err is or wraps ErrMissingFunction.
func IsNoTuplesTableErr ¶
IsNoTuplesTableErr returns true if err is or wraps ErrNoTuplesTable.
func IsValidationError ¶ added in v0.2.0
IsValidationError returns true if err is or wraps a ValidationError.
func WithDecisionContext ¶
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 ¶
Get retrieves a cached permission check result. Returns (allowed, err, found). If found is false, the entry doesn't exist or is expired.
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 ¶
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
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:
- Checker-level: Set via WithDecision() at Checker construction
- 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 ¶
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 ¶
FGAObject returns the object itself, implementing ObjectLike. This allows Object to be used directly in permission checks.
func (Object) FGASubject ¶
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.
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 ¶
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:
- Context decision (via WithDecisionContext)
- Checker decision (via WithDecision)
- Database check
By default, context decisions are NOT consulted. This opt-in design ensures explicit control over when context can override authorization.
func WithDecision ¶
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
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 ¶
FGARelation returns the relation itself, implementing RelationLike.
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.