Documentation
¶
Overview ¶
Package schema provides OpenFGA schema types and transformation logic for melange.
This package contains the core data structures and algorithms for converting OpenFGA authorization models into database-friendly representations. It sits between the tooling package (which parses .fga files) and the runtime checker (which executes permission checks).
Package Responsibilities ¶
The schema package handles three critical transformations:
- Schema representation (TypeDefinition, RelationDefinition) - parsed FGA models
- Database model generation (ToAuthzModels) - flattening rules for SQL queries
- Precomputation (ComputeRelationClosure, ToUsersetRules) - optimizing runtime checks
Key Types ¶
TypeDefinition represents a parsed object type from an FGA schema. Each type has relations that define permissions and roles. For example:
type repository
relations
define owner: [user]
define can_read: owner or [user]
AuthzModel represents a row in the melange_model table. The ToAuthzModels function converts TypeDefinitions into database rows, performing transitive closure of implied-by relationships to support efficient permission checks.
ClosureRow represents precomputed transitive relationships in melange_relation_closure. This table eliminates recursive SQL during permission checks by answering "what relations satisfy this relation?" with a simple JOIN.
Migration Workflow ¶
The Migrator orchestrates loading schemas into PostgreSQL:
- ApplyDDL - creates tables and functions (idempotent)
- Parse schema via tooling package (returns []TypeDefinition)
- MigrateWithTypes - validates, transforms, and loads data
Typical usage:
import "github.com/pthm/melange/tooling"
types, _ := tooling.ParseSchema("schemas/schema.fga")
migrator := schema.NewMigrator(db, "schemas")
err := migrator.MigrateWithTypes(ctx, types)
Code Generation ¶
GenerateGo produces type-safe Go constants from schema types. This enables compile-time checking of permission checks:
types, _ := tooling.ParseSchema("schema.fga")
schema.GenerateGo(file, types, schema.DefaultGenerateConfig())
Generated code includes ObjectType constants, Relation constants, and constructor functions for creating melange.Object values.
Validation ¶
DetectCycles validates schemas before migration. It checks for:
- Implied-by cycles within a type (admin -> owner -> admin)
- Cross-type parent relation cycles
- Allows hierarchical recursion (folder -> parent folder)
Invalid schemas are rejected with ErrCyclicSchema before reaching the database.
Relationship to Other Packages ¶
The schema package is dependency-free (stdlib only) and imported by both:
- tooling package (adds OpenFGA parser, provides convenience functions)
- root melange package (uses Execer interface but no other types)
This layering keeps the core runtime (melange package) lightweight while supporting rich schema manipulation in tooling contexts.
Index ¶
- Variables
- func DetectCycles(types []TypeDefinition) error
- func GenerateGo(w io.Writer, types []TypeDefinition, cfg *GenerateConfig) error
- func IsCyclicSchemaErr(err error) bool
- func RelationSubjects(types []TypeDefinition, objectType string, relation string) []string
- func SubjectTypes(types []TypeDefinition) []string
- type AuthzModel
- type ClosureRow
- type Execer
- type GenerateConfig
- type IntersectionGroup
- type Migrator
- type ParentRelationCheck
- type RelationDefinition
- type Status
- type SubjectTypeRef
- type TypeDefinition
- type UsersetRule
Constants ¶
This section is empty.
Variables ¶
var ErrCyclicSchema = errors.New("melange/schema: cyclic schema")
ErrCyclicSchema is returned when the schema contains a cycle in the relation graph.
Functions ¶
func DetectCycles ¶
func DetectCycles(types []TypeDefinition) error
DetectCycles checks for cycles in the relation graph. It validates both implied-by cycles (within a single type) and parent relation cycles (across types). Returns an error describing the cycle if one is found.
This function is called by both GenerateGo and MigrateWithTypes to catch invalid schemas before they cause runtime issues.
Example cyclic schemas that would be detected:
// Implied-by cycle:
type resource
relations
define admin: [user] or owner
define owner: [user] or admin // CYCLE: admin ↔ owner
// Parent relation cycle:
type organization
relations
define repo: [repository]
define can_read: can_read from repo
type repository
relations
define org: [organization]
define can_read: can_read from org // CYCLE: crosses types
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 IsCyclicSchemaErr ¶
IsCyclicSchemaErr returns true if err is or wraps ErrCyclicSchema.
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, _ := tooling.ParseSchema("schema.fga")
subjects := schema.RelationSubjects(types, "repository", "owner")
// Returns: ["user"] - only users can be repository owners
readers := schema.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, _ := tooling.ParseSchema("schema.fga")
subjects := schema.SubjectTypes(types)
// Returns: ["user", "organization", "team"]
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)
SubjectWildcard *bool // Whether wildcard subjects are allowed for SubjectType
// Excluded parent relation for tuple-to-userset exclusions.
ExcludedParentRelation *string // Parent relation to exclude (for "but not rel from parent")
ExcludedParentType *string // Linking relation for the excluded parent relation
// New fields for userset references and intersection support
SubjectRelation *string // For userset refs [type#relation]: the relation part
RuleGroupID *int64 // Groups rules that form an intersection
RuleGroupMode *string // 'intersection' for AND, 'union' or NULL for OR
CheckRelation *string // For intersection: which relation to check
CheckExcludedRelation *string // For intersection: exclusion on check_relation (e.g., "editor but not owner")
CheckParentRelation *string // For intersection: parent relation to check (tuple-to-userset)
CheckParentType *string // For intersection: linking relation on current object
}
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)
- Userset: SubjectType and SubjectRelation set (e.g., [group#member])
- Intersection: RuleGroupID and RuleGroupMode set (AND semantics)
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"
- Intersection groups: "viewer: writer and editor" (all must be satisfied)
The check_permission function queries these rows to evaluate permissions recursively, following the graph of implications and parent relationships.
type ClosureRow ¶
type ClosureRow struct {
ObjectType string
Relation string
SatisfyingRelation string
ViaPath []string // For debugging: path from relation to satisfying_relation
}
ClosureRow represents a row in the melange_relation_closure table. The closure table is a critical optimization that precomputes transitive implied-by relationships at schema load time, eliminating the need for recursive function calls during permission checks.
Each row indicates that having satisfying_relation grants the relation on objects of object_type. For example, in a role hierarchy where owner -> admin -> member:
- {object_type: "repo", relation: "member", satisfying_relation: "owner"}
- {object_type: "repo", relation: "member", satisfying_relation: "admin"}
- {object_type: "repo", relation: "member", satisfying_relation: "member"}
This allows check_permission to evaluate "does user have member?" with a simple JOIN rather than recursive traversal: just check if they have ANY of the satisfying relations.
func ComputeRelationClosure ¶
func ComputeRelationClosure(types []TypeDefinition) []ClosureRow
ComputeRelationClosure computes the transitive closure for all relations. For each relation, it finds all relations that can satisfy it (directly or transitively).
This is a build-time optimization. Without closure, check_permission would need recursive SQL functions to walk implied-by chains. With closure, a single JOIN against melange_relation_closure resolves the entire hierarchy.
Example: For schema owner -> admin -> member:
- member is satisfied by: member, admin, owner
- admin is satisfied by: admin, owner
- owner is satisfied by: owner
The closure table enables O(1) lookups instead of O(depth) recursion, which is critical for deeply nested role hierarchies.
type Execer ¶
type Execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
Execer is the minimal interface needed for schema migration operations. Implemented by *sql.DB, *sql.Tx, and *sql.Conn.
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 IntersectionGroup ¶
type IntersectionGroup struct {
Relations []string // Relations that must all be satisfied (AND)
ParentRelations []ParentRelationCheck // Parent inheritance checks (tuple-to-userset)
Exclusions map[string][]string // Per-relation exclusions: relation -> list of excluded relations
}
IntersectionGroup represents a group of relations that must ALL be satisfied. For "viewer: writer and editor", the group would be ["writer", "editor"]. For "viewer: writer and (editor but not owner)", the group would be ["writer", "editor"] with Exclusions["editor"] = ["owner"].
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 := schema.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, closure table, and functions. This is idempotent (CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE FUNCTION, CREATE INDEX IF NOT EXISTS).
The DDL creates:
- melange_model table with performance indexes (stores parsed FGA schema)
- melange_relation_closure table (precomputed transitive closure)
- 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:
- Validates the schema (checks for cycles)
- Applies DDL (creates tables and functions)
- Converts type definitions to authorization models
- Computes relation closure for efficient implied-by resolution
- Truncates and repopulates melange_model and melange_relation_closure
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 ParentRelationCheck ¶
type ParentRelationCheck struct {
Relation string // Relation to check on the parent object (e.g., "viewer")
ParentType string // Linking relation on the current object (e.g., "parent")
}
ParentRelationCheck represents a tuple-to-userset check in an intersection group. For "viewer: writer and (viewer from parent)", this captures the parent relation.
type RelationDefinition ¶
type RelationDefinition struct {
Name string // Relation name: "owner", "can_read", etc.
SubjectTypes []string // Direct subject types: ["user"], ["organization"] (legacy)
ImpliedBy []string // Relations that imply this one: ["owner", "admin"]
ParentRelation string // For inheritance: "can_read from org" → "can_read" (legacy)
ParentType string // The relation linking to parent: "org", "repo" (legacy)
ParentRelations []ParentRelationCheck
ExcludedRelation string // For exclusions: "can_read but not author" -> "author" (deprecated, use ExcludedRelations)
ExcludedRelations []string // For nested exclusions: "(a but not b) but not c" -> ["b", "c"]
// ExcludedParentRelations captures tuple-to-userset exclusions like "but not viewer from parent".
ExcludedParentRelations []ParentRelationCheck
// ExcludedIntersectionGroups captures exclusions that require ALL relations in a group.
// For "viewer: writer but not (editor and owner)", this is [[editor, owner]].
ExcludedIntersectionGroups []IntersectionGroup
// SubjectTypeRefs provides detailed subject type info including userset relations.
// For [user, group#member], this would contain:
// - {Type: "user", Relation: ""}
// - {Type: "group", Relation: "member"}
SubjectTypeRefs []SubjectTypeRef
// IntersectionGroups contains groups of relations that must ALL be satisfied.
// Each group is an AND (intersection), multiple groups are OR'd together.
// For "viewer: writer and editor", IntersectionGroups = [["writer", "editor"]]
// For "viewer: (a and b) or (c and d)", IntersectionGroups = [["a","b"], ["c","d"]]
IntersectionGroups []IntersectionGroup
}
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)
- Userset: granted via group membership (SubjectTypeRefs with Relation set)
- Intersection: granted if ALL relations in a group are satisfied
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
// ClosureCount is the number of rows in the melange_relation_closure table.
// This table stores precomputed transitive implied-by relationships.
ClosureCount int64
// IndexCount is the number of melange-related indexes found.
// Expected to be at least 5 after a successful migration.
IndexCount int
// TuplesExists indicates if the melange_tuples relation exists (view, table, or materialized view).
// This must be created by the user to map their domain tables.
TuplesExists bool
}
Status represents the current migration state. Use GetStatus to check if the authorization system is properly configured.
type SubjectTypeRef ¶
type SubjectTypeRef struct {
Type string // Subject type: "user", "group", etc.
Relation string // For userset refs: the relation (e.g., "member" in [group#member])
Wildcard bool // True if this is a wildcard reference (user:*)
}
SubjectTypeRef represents a subject type reference in a relation definition. For userset references like [group#member], Type is "group" and Relation is "member". For direct references like [user], Type is "user" and Relation is empty.
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.
type UsersetRule ¶
type UsersetRule struct {
ObjectType string
Relation string
TupleRelation string
SubjectType string
SubjectRelation string
SubjectRelationSatisfying string
}
UsersetRule represents a precomputed userset rule with relation closure applied. Userset rules handle permissions granted via group membership, expressed in OpenFGA as [group#member]. Unlike direct subject types where the tuple directly grants permission, usersets require checking if the subject has the specified relation on the group object.
Each row means: a tuple with tuple_relation on object_type can satisfy relation when the tuple subject is subject_type#subject_relation.
The rules are precomputed by expanding userset references through the relation closure table. This allows SQL to resolve userset permissions efficiently without nested subqueries for each implied relation.
Example: For "viewer: [group#member]" where admin->member, the rules include:
- {relation: "viewer", tuple_relation: "viewer", subject_type: "group", subject_relation: "member", subject_relation_satisfying: "member"}
- {relation: "viewer", tuple_relation: "viewer", subject_type: "group", subject_relation: "member", subject_relation_satisfying: "admin"}
This enables check_permission to match tuples where the subject has either member or admin on the group, without recursive relation resolution at query time.
func ToUsersetRules ¶
func ToUsersetRules(types []TypeDefinition, closureRows []ClosureRow) []UsersetRule
ToUsersetRules expands userset references using the relation closure. This precomputes which tuple relations can satisfy a target relation for userset rules.
The expansion combines two sources of transitivity:
- Object relation closure: viewer might be satisfied by editor (implied-by)
- Subject relation closure: member might be satisfied by admin (implied-by)
By precomputing the cross-product of these closures, SQL can match userset permissions with a simple JOIN instead of recursive CTEs for each check.
This is analogous to ComputeRelationClosure but handles the subject-side relation traversal required for userset references.