schema

package
v0.2.0 Latest Latest
Warning

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

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

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:

  1. Schema representation (TypeDefinition, RelationDefinition) - parsed FGA models
  2. Database model generation (ToAuthzModels) - flattening rules for SQL queries
  3. 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:

  1. ApplyDDL - creates tables and functions (idempotent)
  2. Parse schema via tooling package (returns []TypeDefinition)
  3. 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

Constants

This section is empty.

Variables

View Source
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

func IsCyclicSchemaErr(err error) bool

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:

  1. Creates melange_model table (if not exists)
  2. Creates/replaces check_permission and list_accessible_* functions
  3. 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

func NewMigrator(db Execer, schemasDir string) *Migrator

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

func (m *Migrator) ApplyDDL(ctx context.Context) error

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

func (m *Migrator) GetStatus(ctx context.Context) (*Status, error)

GetStatus returns the current migration status. Useful for health checks or migration diagnostics.

func (*Migrator) HasSchema

func (m *Migrator) HasSchema() bool

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:

  1. Validates the schema (checks for cycles)
  2. Applies DDL (creates tables and functions)
  3. Converts type definitions to authorization models
  4. Computes relation closure for efficient implied-by resolution
  5. 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

func (m *Migrator) SchemaPath() string

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:

  1. Object relation closure: viewer might be satisfied by editor (implied-by)
  2. 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.

Jump to

Keyboard shortcuts

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