Skip to main content
Version: 0.1.0

๐Ÿ”‘ Feature Gating & Licensing

go-saga-orchestration ships under the permissive MIT license โ€” you can embed it in anything, commercial or free, with no royalty or feature restriction. That software license is separate from the engine's feature-gating layer described on this page.

The feature-gating layer is a single authorization hook the engine calls before running gated work. It is deliberately generic: you can use it to enforce paid licensing tiers, to implement RBAC (role-based access control), or both โ€” the engine doesn't care what the answer means, only whether a (tenant, feature) pair is allowed.


The resolver hookโ€‹

Every gate goes through one interface:

// licensing.Resolver
type Resolver interface {
IsFeatureEnabled(
ctx context.Context,
tenantID *uuid.UUID, // the principal: a tenant, org, or user
feature string, // the capability being requested
overrides map[string]bool, // per-request allow/deny, wins over the resolver
) (bool, error)
}

Return true to allow, false to deny. The tenantID is your principal and feature is your permission โ€” how you map them is entirely up to your implementation. That is what makes the same hook serve licensing and RBAC.


What is gated, and whenโ€‹

Each verb belongs to a license group. A group maps to a feature flag; the engine asks the resolver whether that flag is enabled. The common group is never gated.

License groupFeature flagExample verbs
common(never gated)set_var, transform, noop, log
waitswf.timerswait_duration, wait_until
events_and_signalswf.event_drivenemit_event, wait_for_event, emit_signal
parallel_controlwf.parallelparallel, foreach
loops_and_recoverywf.loops_recoverywhile, try_catch, cancel
human_interactionwf.user_tasksmanual_approval, collect_input
compositionswf.compositionssub_saga, spawn_saga
external_io_advancedwf.external_ioauthenticated http_request, webhook_emit
observabilitywf.observabilitymetric_emit
(cron triggers)wf.cron_triggersscheduled trigger starts

The check fires in three places:

  • At publish โ€” ValidateDefinition rejects a workflow whose steps reference a feature the tenant lacks, so unlicensed workflows never go live.
  • At runtime โ€” each step re-checks its feature before executing (entitlements can change between publish and run).
  • At trigger time โ€” cron-scheduled starts check wf.cron_triggers both when the REST trigger is created and when the dispatcher fires it.

A denied check fails the publish or the step with a license_gate error naming the step, group, and required feature.


Use it for licensing (paid tiers)โ€‹

Map a tenant's purchased plan to the feature flags it unlocks. A free tenant publishing a workflow that uses parallel is rejected; a premium tenant is allowed.

type PlanResolver struct {
planOf func(uuid.UUID) string // tenant -> "free" | "premium"
unlocks map[string]map[string]bool // plan -> feature -> enabled
}

func (r PlanResolver) IsFeatureEnabled(_ context.Context, tenant *uuid.UUID, feature string, overrides map[string]bool) (bool, error) {
if v, ok := overrides[feature]; ok {
return v, nil // per-request override wins (e.g. a trial grant)
}
if tenant == nil {
return false, nil
}
return r.unlocks[r.planOf(*tenant)][feature], nil
}

Use it for RBACโ€‹

The exact same hook is a per-principal permission check. Treat feature as a permission name and tenantID as the role-bearing principal, and deny verbs a role isn't allowed to run โ€” independent of whether anyone paid.

// Allow only roles that hold the permission for the requested feature.
func (r RoleResolver) IsFeatureEnabled(_ context.Context, principal *uuid.UUID, feature string, _ map[string]bool) (bool, error) {
if principal == nil {
return false, nil
}
role := r.roleOf(*principal) // e.g. "operator", "viewer"
return r.permits(role, feature), nil // RBAC policy lookup
}

Because licensing and RBAC share one interface, you can also compose them โ€” wrap a plan check and a role check and require both to pass.


Built-in resolversโ€‹

ResolverUse
licensing.StubAllowAll{}Allows everything. The default for saga.InMemory() and tests.
licensing.NewCached(inner, ttl)Wraps any resolver with a per-(tenant, feature) cache and applies per-request overrides. Wrap your real resolver with this in production.
licensing.HTTPFeatureResolver{BaseURL: ...}Resolves flags by GET-ing a remote service that returns {"features":[...]} for a tenant. Wrap it with Cached.

Wiring it inโ€‹

Pass your resolver to saga.New; omit it (or use saga.InMemory()) to allow everything during development.

sc, err := saga.New(saga.Options{
Store: store,
Licensing: licensing.NewCached(PlanResolver{ /* ... */ }, 5*time.Minute),
})

See Embedding for the rest of the production options and Testing for asserting gated behavior with StubAllowAll.