Melange is a pure PostgreSQL + Go authorization library inspired by OpenFGA/Zanzibar and the rover-app pgfga implementation: https://github.com/rover-app/pgfga
Melange provides fine-grained authorization with:
- PostgreSQL functions for permission checks
- Zero tuple sync (permissions derived from a view over your tables)
- Optional code generation for type-safe constants
- Zero runtime dependencies (core library is pure stdlib)
Melange is split into two modules for clean dependency isolation:
| Module | Purpose | Dependencies |
|---|---|---|
github.com/pthm/melange |
Core runtime (checker, types, errors) | stdlib only |
github.com/pthm/melange/tooling |
Schema parsing, CLI, migration helpers | OpenFGA parser |
Most applications only import the core module at runtime. The tooling module is used during development (CLI, code generation) or if you need programmatic schema parsing.
- PostgreSQL database
- A
.fgaschema file (parsed by CLI or tooling module) - A
melange_tuplesview that maps your domain tables into tuples
- Create a schema file (
schema.fga):
model
schema 1.1
type user
type repository
relations
define owner: [user]
define can_read: owner
- Create a
melange_tuplesview:
CREATE OR REPLACE VIEW melange_tuples AS
SELECT
'user' AS subject_type,
user_id::text AS subject_id,
'owner' AS relation,
'repository' AS object_type,
repository_id::text AS object_id
FROM repository_owners;- Apply Melange infrastructure + schema:
melange migrate --db postgres://localhost/mydb --schemas-dir schemas- Generate type-safe Go constants:
melange generate --schemas-dir schemas --generate-dir internal/authz --generate-pkg authz- Check permissions in Go:
checker := melange.NewChecker(db)
ok, err := checker.Check(ctx, authz.User("123"), authz.RelCanRead, authz.Repository("456"))
if err != nil {
return err
}
if !ok {
return ErrForbidden
}- Objects: Both subjects and resources are modeled as objects.
Object{Type: "user", ID: "123"}
- Relations: Simple strings (generated constants are optional).
- Wildcard: Use
*as a subject ID for public access (type:*).
Melange works with *sql.DB, *sql.Tx, or *sql.Conn.
checker := melange.NewChecker(db)
ok, err := checker.Check(ctx, subject, relation, object)
ids, err := checker.ListObjects(ctx, subject, relation, objectType)cache := melange.NewCache(melange.WithTTL(time.Minute))
checker := melange.NewChecker(db, melange.WithCache(cache))For tests or admin tools:
checker := melange.NewChecker(db, melange.WithDecision(melange.DecisionAllow))Sentinel errors:
melange.ErrNoTuplesTable- melange_tuples view doesn't existmelange.ErrMissingModel- melange_model table doesn't existmelange.ErrEmptyModel- Model table exists but is emptymelange.ErrInvalidSchema- Schema parsing failedmelange.ErrMissingFunction- SQL functions not installed
Helpers:
melange.IsNoTuplesTableErr(err)melange.IsMissingModelErr(err)melange.IsEmptyModelErr(err)melange.IsInvalidSchemaErr(err)melange.IsMissingFunctionErr(err)
Query schema definitions to build dynamic UIs or introspect the model:
types := []melange.TypeDefinition{...} // from tooling.ParseSchema
// Get all unique subject types across the schema
subjects := melange.SubjectTypes(types)
// e.g., ["user", "team", "organization"]
// Get subject types for a specific relation
allowed := melange.RelationSubjects(types, "repository", "owner")
// e.g., ["user"] (only users can be owners)For programmatic schema loading (without the CLI):
import "github.com/pthm/melange/tooling"
// Parse and migrate in one step
err := tooling.Migrate(ctx, db, "schemas")
// Or with more control:
types, err := tooling.ParseSchema("schemas/schema.fga")
migrator := melange.NewMigrator(db, "schemas")
err = migrator.MigrateWithTypes(ctx, types)The Migrator also supports individual steps:
migrator := melange.NewMigrator(db, "schemas")
// Apply DDL only (tables + functions)
err := migrator.ApplyDDL(ctx)
// Load schema into model table
err := migrator.MigrateWithTypes(ctx, types)
// Check current status
status, err := migrator.GetStatus(ctx)
// status.SchemaExists, status.ModelCountmelange [command] [flags]
Commands:
migrate Apply schema to database
generate Generate Go types from schema
validate Validate schema syntax
status Show current schema status
Melange is designed for low-latency permission checks with predictable scaling characteristics. All benchmarks run against PostgreSQL with varying tuple counts.
| Operation | 1K Tuples | 10K Tuples | 100K Tuples | 1M Tuples | Scaling |
|---|---|---|---|---|---|
| Direct Membership | 426μs | 397μs | 384μs | 428μs | O(1) |
| Inherited Permission | 995μs | 1.1ms | 1.4ms | 3.4ms | O(log n) |
| Exclusion Pattern | 1.8ms | 3.4ms | 18ms | 173ms | O(n) |
| Denied Permission | 612μs | 683μs | 739μs | 1.2ms | O(log n) |
Direct membership checks are constant-time regardless of tuple count. The ~400μs baseline is dominated by network round-trip latency.
Inherited permissions (role hierarchies via from parent) scale logarithmically thanks to precomputed transitive closure.
Exclusion patterns (but not) scale linearly and should be avoided in hot paths for large deployments.
| Operation | 1K | 10K | 100K | 1M |
|---|---|---|---|---|
| ListObjects | 2.3ms | 23ms | 192ms | 1.5s |
| ListSubjects | 708μs | 6.3ms | 42ms | 864ms |
List operations scale linearly with tuple count. For large datasets, use application-layer pagination or pre-filter candidates.
| Scenario | Latency | Speedup |
|---|---|---|
| Without cache | 980μs | — |
| With cache (warm) | 79ns | 12,400× |
Enable caching for dramatic performance improvements on repeated checks:
cache := melange.NewCache(melange.WithTTL(time.Minute))
checker := melange.NewChecker(db, melange.WithCache(cache))Recommendations by Scale
| Scale | Expected Latency | Recommendations |
|---|---|---|
| < 10K tuples | < 1ms | No optimization needed |
| 10K–100K tuples | 1–5ms | Enable caching for repeated checks |
| 100K–1M tuples | 5–20ms | Avoid exclusion patterns in hot paths |
| > 1M tuples | 20ms+ | Use caching; paginate list operations |
Memory Overhead
- Check operations: ~1.3KB, 29 allocations per call
- List operations: ~1–2KB base + result size
Memory allocation in the Go runtime is constant regardless of tuple count—the SQL-based approach keeps Go-side overhead minimal.
