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 stored in melange_model.
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 ¶
- Variables
- func GenerateGo(w io.Writer, types []TypeDefinition, cfg *GenerateConfig) error
- func IsEmptyModelErr(err error) bool
- func IsInvalidSchemaErr(err error) bool
- func IsMissingFunctionErr(err error) bool
- func IsMissingModelErr(err error) bool
- func IsNoTuplesTableErr(err error) bool
- func RelationSubjects(types []TypeDefinition, objectType string, relation string) []string
- func SubjectTypes(types []TypeDefinition) []string
- func WithDecisionContext(ctx context.Context, decision Decision) context.Context
- type AuthzModel
- type Cache
- type CacheImpl
- type CacheOption
- type Checker
- func (c *Checker) Check(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) ListSubjects(ctx context.Context, object ObjectLike, relation RelationLike, ...) ([]string, error)
- func (c *Checker) Must(ctx context.Context, subject SubjectLike, relation RelationLike, ...)
- type Decision
- type Execer
- type GenerateConfig
- type Migrator
- type Object
- type ObjectLike
- type ObjectType
- type Option
- type Querier
- type Relation
- type RelationDefinition
- type RelationLike
- type Status
- type SubjectLike
- type TypeDefinition
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoTuplesTable is returned when the melange_tuples view doesn't exist. // This typically means the application hasn't created the view over its // domain tables. See the melange documentation for view creation examples. ErrNoTuplesTable = errors.New("melange: melange_tuples view/table not found") // ErrMissingModel is returned when the melange_model table doesn't exist. // Run `melange migrate` to create the table and load the FGA schema. ErrMissingModel = errors.New("melange: melange_model table missing") // ErrEmptyModel is returned when the melange_model table exists but is empty. // This means the schema hasn't been loaded. Run `melange migrate` to // parse the .fga file and populate the model. ErrEmptyModel = errors.New("melange: authorization model empty") // 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") )
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 GenerateGo ¶
func GenerateGo(w io.Writer, types []TypeDefinition, cfg *GenerateConfig) error
GenerateGo writes Go code for the types and relations from a parsed schema. Unlike some FGA implementations, this generates constants for ALL relations, not just those with "can_" prefix (unless filtered via config).
The generated code includes:
- ObjectType constants (TypeUser, TypeRepository, etc.)
- Relation constants (RelCanRead, RelOwner, etc.)
- Constructor functions (User(id), Repository(id), etc.)
- Wildcard constructors (AnyUser(), AnyRepository(), etc.)
The constructors return melange.Object values, enabling type-safe permission checks:
checker.Check(ctx, authz.User(123), authz.RelCanRead, authz.Repository(456))
Wildcard constructors support public access patterns:
// Grant all users permission
INSERT INTO tuples (subject_type, subject_id, relation, object_type, object_id)
VALUES ('user', '*', 'can_read', 'repository', '456')
checker.Check(ctx, authz.AnyUser(), authz.RelCanRead, authz.Repository(456))
func IsEmptyModelErr ¶
IsEmptyModelErr returns true if err is or wraps ErrEmptyModel.
func IsInvalidSchemaErr ¶
IsInvalidSchemaErr returns true if err is or wraps ErrInvalidSchema.
func IsMissingFunctionErr ¶
IsMissingFunctionErr returns true if err is or wraps ErrMissingFunction.
func IsMissingModelErr ¶
IsMissingModelErr returns true if err is or wraps ErrMissingModel.
func IsNoTuplesTableErr ¶
IsNoTuplesTableErr returns true if err is or wraps ErrNoTuplesTable.
func RelationSubjects ¶
func RelationSubjects(types []TypeDefinition, objectType string, relation string) []string
RelationSubjects returns the subject types that can have a specific relation on objects of the given type. This is useful for understanding who can be granted a particular permission.
Example:
types, _ := melange.ParseSchema("schema.fga")
subjects := melange.RelationSubjects(types, "repository", "owner")
// Returns: ["user"] - only users can be repository owners
readers := melange.RelationSubjects(types, "repository", "can_read")
// Returns: ["user", "organization"] - users and orgs can read repositories
func SubjectTypes ¶
func SubjectTypes(types []TypeDefinition) []string
SubjectTypes returns all types that can be subjects in authorization checks. A type is a subject type if it appears in any relation's SubjectTypes list. This is useful for understanding which types can be the "who" in permission checks.
Example:
types, _ := melange.ParseSchema("schema.fga")
subjects := melange.SubjectTypes(types)
// Returns: ["user", "organization", "team"]
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.
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.
Note: The Checker does NOT automatically consult this context value. This is a utility for applications that want to propagate authorization decisions through their own middleware or handler chains.
Types ¶
type AuthzModel ¶
type AuthzModel struct {
ID int64
ObjectType string // Object type this rule applies to
Relation string // Relation this rule defines
SubjectType *string // Allowed subject type (for direct rules)
ImpliedBy *string // Implying relation (for role hierarchy)
ParentRelation *string // Parent relation to check (for inheritance)
ExcludedRelation *string // Relation to exclude (for "but not" rules)
}
AuthzModel represents an entry in the melange_model table. Each row defines one authorization rule that check_permission evaluates.
The table stores the flattened authorization model, precomputing transitive closures and normalizing rules for efficient query execution.
Rule types:
- Direct: SubjectType is set, others NULL (user can have relation)
- Implied: ImpliedBy is set (having one relation grants another)
- Parent: ParentRelation and SubjectType set (inherit from parent object)
- Exclusive: ExcludedRelation set (permission denied if exclusion holds)
func ToAuthzModels ¶
func ToAuthzModels(types []TypeDefinition) []AuthzModel
ToAuthzModels converts parsed type definitions to database models. This is the critical transformation that enables permission checking.
The conversion performs transitive closure of implied_by relationships to support role hierarchies. For example, if owner → admin and admin → member, the closure ensures owner also implies member without explicit declaration.
Each AuthzModel row represents one authorization rule:
- Direct subject types: "repository.can_read allows user"
- Implied relations: "repository.can_read implied by can_write"
- Parent inheritance: "change.can_read from repository.can_read"
- Exclusions: "change.can_read but not is_author"
The check_permission function queries these rows to evaluate permissions recursively, following the graph of implications and parent relationships.
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 the melange_model table (parsed FGA schema) 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) 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)
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)
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 Decision ¶
type Decision int
Decision allows bypassing DB checks for admin tools and tests. Decisions are set at Checker construction time via WithDecision, making the bypass explicit and visible in code.
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 GenerateConfig ¶
type GenerateConfig struct {
// Package name for generated code. Default: "authz"
Package string
// RelationPrefixFilter is a prefix filter for relation names.
// Only relations with this prefix will have constants generated.
// If empty, all relations will be generated.
//
// Example: "can_" generates only permission relations, omitting roles.
RelationPrefixFilter string
// IDType specifies the type to use for object IDs.
// Default: "string"
// The type must implement fmt.Stringer or be safely convertible to string.
//
// Common values: "string", "int64", "uuid.UUID"
// The generated code uses fmt.Sprint(id) for conversion.
IDType string
}
GenerateConfig configures code generation. The generator produces Go code with type-safe constants for object types and relations defined in the FGA schema.
func DefaultGenerateConfig ¶
func DefaultGenerateConfig() *GenerateConfig
DefaultGenerateConfig returns sensible defaults. Package: "authz", no relation filter (all relations), string IDs.
type Migrator ¶
type Migrator struct {
// contains filtered or unexported fields
}
Migrator handles loading authorization schemas into PostgreSQL. The migrator is idempotent - safe to run on every application startup.
The migration process:
- Creates melange_model table (if not exists)
- Creates/replaces check_permission and list_accessible_* functions
- Loads pre-parsed authorization rules into the database
Usage with Tooling Module ¶
For most use cases, use the tooling module's convenience functions which handle parsing and migration in one step:
import "github.com/pthm/melange/tooling" err := tooling.Migrate(ctx, db, "schemas")
Use the core Migrator directly when you have pre-parsed TypeDefinitions or need fine-grained control (DDL-only, status checks, etc.):
types, _ := tooling.ParseSchema("schemas/schema.fga")
migrator := melange.NewMigrator(db, "schemas")
err := migrator.MigrateWithTypes(ctx, types)
This separation keeps the core melange package free of OpenFGA dependencies.
func NewMigrator ¶
NewMigrator creates a new schema migrator. The schemasDir should contain a schema.fga file in OpenFGA DSL format. The Execer is typically *sql.DB but can be *sql.Tx for testing.
func (*Migrator) ApplyDDL ¶
ApplyDDL applies the melange_model table and functions. This is idempotent (CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE FUNCTION).
The DDL creates:
- melange_model table (stores parsed FGA schema)
- check_permission function (evaluates permissions)
- list_accessible_objects function (reverse lookup)
- has_tuple function (direct tuple checks)
This can be called independently of schema migration to update function implementations without reloading the authorization model.
func (*Migrator) GetStatus ¶
GetStatus returns the current migration status. Useful for health checks or migration diagnostics.
func (*Migrator) HasSchema ¶
HasSchema returns true if the schema file exists. Use this to conditionally run migration or skip if not configured.
func (*Migrator) MigrateWithTypes ¶
func (m *Migrator) MigrateWithTypes(ctx context.Context, types []TypeDefinition) error
MigrateWithTypes performs database migration using pre-parsed type definitions. This is the core migration method used by the tooling package's Migrate function.
The method:
- Applies DDL (creates tables and functions)
- Converts type definitions to authorization models
- Truncates and repopulates melange_model with the new rules
This is idempotent - safe to run multiple times with the same types.
Uses a transaction if the db supports it (*sql.DB). This ensures the schema is updated atomically or not at all.
func (*Migrator) SchemaPath ¶
SchemaPath returns the path to the schema.fga file. Conventionally named schema.fga by OpenFGA tooling.
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.
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 RelationDefinition ¶
type RelationDefinition struct {
Name string // Relation name: "owner", "can_read", etc.
SubjectTypes []string // Direct subject types: ["user"], ["organization"]
ImpliedBy []string // Relations that imply this one: ["owner", "admin"]
ParentRelation string // For inheritance: "can_read from org" → "can_read"
ParentType string // The relation linking to parent: "org", "repo"
ExcludedRelation string // For exclusions: "can_read but not author" → "author"
}
RelationDefinition represents a parsed relation. Relations describe who can have what relationship with an object.
A relation can be:
- Direct: explicitly granted via tuples (SubjectTypes)
- Implied: granted by having another relation (ImpliedBy)
- Inherited: derived from a parent object (ParentRelation, ParentType)
- Exclusive: granted except for excluded subjects (ExcludedRelation)
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 Status ¶
type Status struct {
// SchemaExists indicates if the schema.fga file exists on disk.
SchemaExists bool
// ModelCount is the number of rows in the melange_model table.
// Zero means the schema hasn't been loaded (run `melange migrate`).
ModelCount int64
}
Status represents the current migration state. Use GetStatus to check if the authorization system is properly configured.
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 TypeDefinition ¶
type TypeDefinition struct {
Name string
Relations []RelationDefinition
}
TypeDefinition represents a parsed type from an .fga file. Each type definition describes an object type (user, repository, etc.) and the relations that can exist on objects of that type.