Documentation
¶
Overview ¶
Package approvals runs multi-step approval workflows for SaaS applications, such as an expense approved by a manager and then by finance.
Workflows are defined in code and registered with an Engine by key. Each workflow is an ordered list of steps; a step names its approvers (explicit parties as Refs, roles resolved by the application's ApproverResolver, or both) and a rule: RuleAny, RuleAll or RuleQuorum. A step may carry a deadline, after which the request expires.
Separation of duties is on by default. Requesters cannot decide on their own requests unless the workflow sets AllowSelfApproval, and nobody decides twice on one request: an approver of one step is not eligible on the later ones, and one party counts once on a step even when it is both listed and in a role. Role membership is resolved at the moment of each decision. Requests migrated from 0.1.0 carry PerStepDecisions and keep that release's rule, under which one approver could decide on several steps, so that requests in flight during the upgrade still complete.
Submit creates a Request that stores a snapshot of the workflow's steps, so later code changes never alter requests already in flight; its payload is kept byte for byte. Approve and Reject record one Decision per actor per step; a rejection closes the request, and an approval advances it once the step's rule is satisfied. Cancel withdraws a request, PendingFor pages through an actor's inbox across workflows, History returns the audit trail, and ExpireDue closes requests whose deadline passed; it is safe to run from several instances at once.
Every state change is recorded as a Transition and passed to the configured hooks, exactly once, inside the same store transaction, so applications can enqueue notifications atomically; a hook error aborts the change. Transitions of one request are serialized by a row lock in the store.
The actor performing a call and the tenant it is scoped to travel in the context (WithActor, WithTenant), or come from the application's own context values through Options.ActorFrom and Options.TenantFrom. Every read and every transition is confined to the tenant; a context without a tenant is the empty tenant "", never all tenants.
Errors wrap ErrNotFound, ErrInvalid or ErrConflict, so that applications map them to HTTP statuses the same way as the other Byte Minus packages; ErrNotEligible and ErrNotAuthorized report permission failures.
Storage is pluggable through Store: see the memstore and pgxstore subpackages, and storetest for the conformance suite third-party stores can run.
Example ¶
An expense is approved by the employee's manager, then by two members of the finance team within 48 hours.
package main
import (
"context"
"errors"
"fmt"
"slices"
"time"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
func main() {
finance := approvals.ResolverFunc(func(_ context.Context, actor approvals.Ref, role, tenant string) (bool, error) {
return role == "finance" && tenant == "acme" && actor.Type == "user" &&
slices.Contains([]string{"fay", "finn"}, actor.ID), nil
})
notify := func(_ context.Context, t approvals.Transition, r approvals.Request) error {
// In production, enqueue a notification job here; it commits or
// rolls back with the transition.
fmt.Printf("hook: %s (step %d -> %d)\n", t.Kind, t.FromStep, t.ToStep)
return nil
}
engine, err := approvals.New(memstore.New(), approvals.Options{
Resolver: finance,
Hooks: []approvals.Hook{notify},
})
if err != nil {
panic(err)
}
err = engine.Register(approvals.Workflow{
Key: "expense",
Steps: []approvals.Step{
{Name: "manager", Approvers: []approvals.Ref{{Type: "user", ID: "maria"}}, Rule: approvals.RuleAny},
{Name: "finance", Roles: []string{"finance"}, Rule: approvals.RuleQuorum, Quorum: 2, Deadline: 48 * time.Hour},
},
})
if err != nil {
panic(err)
}
as := func(id string) context.Context {
return approvals.WithTenant(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: id}), "acme")
}
req, err := engine.Submit(as("eve"), approvals.SubmitInput{
Workflow: "expense",
Subject: approvals.Ref{Type: "expense", ID: "exp_42"},
Payload: []byte(`{"amount": 125, "currency": "EUR"}`),
})
if err != nil {
panic(err)
}
for _, id := range []string{"maria", "fay", "finn"} {
req, err = engine.Approve(as(id), req.ID, approvals.DecideInput{Comment: "ok"})
if err != nil {
panic(err)
}
fmt.Printf("%s approved: status %s, step %d\n", id, req.Status, req.CurrentStep)
}
_, err = engine.Approve(as("eve"), req.ID, approvals.DecideInput{})
fmt.Println(err)
fmt.Println(errors.Is(err, approvals.ErrConflict))
}
Output: hook: submitted (step 0 -> 0) hook: step_advanced (step 0 -> 1) maria approved: status pending, step 1 fay approved: status pending, step 1 hook: approved (step 1 -> 1) finn approved: status approved, step 1 approvals: request is not pending: status is approved true
Index ¶
- Constants
- Variables
- func TenantFrom(ctx context.Context) (string, bool)
- func WithActor(ctx context.Context, actor Ref) context.Context
- func WithTenant(ctx context.Context, tenantID string) context.Context
- type ApproverResolver
- type CancelAuthorizer
- type CancelAuthorizerFunc
- type DecideInput
- type Decision
- type DueQuery
- type Engine
- func (e *Engine) Approve(ctx context.Context, id uuid.UUID, in DecideInput) (Request, error)
- func (e *Engine) Cancel(ctx context.Context, id uuid.UUID, reason string) (Request, error)
- func (e *Engine) ExpireDue(ctx context.Context) (int, error)
- func (e *Engine) Get(ctx context.Context, id uuid.UUID) (Request, error)
- func (e *Engine) History(ctx context.Context, id uuid.UUID) (History, error)
- func (e *Engine) PendingFor(ctx context.Context, actor Ref, opts PageOptions) (RequestPage, error)
- func (e *Engine) Register(w Workflow) error
- func (e *Engine) Reject(ctx context.Context, id uuid.UUID, in DecideInput) (Request, error)
- func (e *Engine) Submit(ctx context.Context, in SubmitInput) (Request, error)
- type History
- type Hook
- type Keyset
- type Options
- type Outcome
- type PageOptions
- type PendingQuery
- type Ref
- type Request
- type RequestPage
- type ResolverFunc
- type RoleQuery
- type Rule
- type Status
- type Step
- type Store
- type SubmitInput
- type Transition
- type TransitionKind
- type Workflow
Examples ¶
Constants ¶
const ( // DefaultPageSize is the page size PendingFor uses when the limit is 0. DefaultPageSize = 50 // MaxPageSize caps the page size PendingFor returns; larger limits are // reduced to it. MaxPageSize = 200 )
const ( // MaxWorkflowSteps is the most steps a workflow may have. MaxWorkflowSteps = 32 // MaxWorkflowApprovers is the most explicit approvers a workflow may // list, counted over all its steps. MaxWorkflowApprovers = 256 // MaxWorkflowRoles is the most roles a workflow may name, counted over // all its steps. MaxWorkflowRoles = 64 )
Caps on a workflow definition, enforced by Register. Every request carries a snapshot of its workflow's steps, so they also bound the size of each stored request and the cost of checking that a definition can complete.
const MaxCommentSize = 4096
MaxCommentSize is the largest decision comment or cancellation reason, in bytes, that the engine accepts.
const MaxPayloadDepth = 64
MaxPayloadDepth is the deepest nesting of arrays and objects that Submit accepts in a payload. It keeps decoders that recurse, PostgreSQL's among them, far from their stack limits.
const MaxPayloadSize = 64 << 10
MaxPayloadSize is the largest request payload, in bytes, that Submit accepts. Payloads describe the business object under approval; anything larger belongs in the application's own tables, referenced by Subject.
Variables ¶
var ( // ErrNotFound reports a request that does not exist or belongs to // another tenant; the two cases are deliberately indistinguishable. ErrNotFound error = baseError("approvals: request not found") // ErrInvalid reports input that failed validation. The message names // the field and the reason. ErrInvalid error = baseError("approvals: invalid input") // ErrConflict reports a uniqueness violation, or a request whose state // no longer allows the operation. ErrConflict error = baseError("approvals: conflict") )
The three sentinels every Byte Minus package exports, so that applications map all of them to HTTP statuses the same way. Every other error below wraps one of them, except ErrNotEligible and ErrNotAuthorized, which report a permission failure rather than bad input or state.
var ( // ErrInvalidWorkflow reports a workflow definition rejected by // Register. It wraps ErrInvalid. ErrInvalidWorkflow error = &kindError{"approvals: invalid workflow", ErrInvalid} // ErrDuplicateWorkflow reports a second Register call with the same // key. It wraps ErrConflict. ErrDuplicateWorkflow error = &kindError{"approvals: workflow already registered", ErrConflict} // ErrUnknownWorkflow reports a Submit for a key that was never // registered. It wraps ErrInvalid. ErrUnknownWorkflow error = &kindError{"approvals: unknown workflow", ErrInvalid} // ErrNoActor reports a call that needs an actor (see WithActor) without // one. It wraps ErrInvalid. ErrNoActor error = &kindError{"approvals: no actor", ErrInvalid} // ErrUnsatisfiable reports a submission whose workflow could never // complete, typically because separation of duties excludes the only // eligible approver. It wraps ErrInvalid. ErrUnsatisfiable error = &kindError{"approvals: workflow cannot be satisfied for this requester", ErrInvalid} // ErrNotPending reports an action on a request that is already closed. // It wraps ErrConflict. ErrNotPending error = &kindError{"approvals: request is not pending", ErrConflict} // ErrAlreadyDecided reports a second decision by the same actor on the // same step: a replay. It wraps ErrConflict. ErrAlreadyDecided error = &kindError{"approvals: actor already decided on this step", ErrConflict} // ErrStaleStep reports a decision aimed at a step that is no longer the // current one. It wraps ErrConflict. ErrStaleStep error = &kindError{"approvals: request is no longer at that step", ErrConflict} // ErrNotEligible reports a decision by an actor who may not decide on // the current step. ErrNotEligible error = baseError("approvals: actor is not eligible") // ErrNotAuthorized reports a cancellation by an actor who may not // cancel. ErrNotAuthorized error = baseError("approvals: actor is not authorized") )
Functions ¶
func TenantFrom ¶
TenantFrom returns the tenant ID stored by WithTenant. The boolean is false when none was set.
func WithActor ¶
WithActor returns a copy of ctx carrying the party performing engine calls: the requester for Submit and Cancel, the approver for Approve and Reject. Applications that already keep the user in the context can set Options.ActorFrom instead.
func WithTenant ¶
WithTenant returns a copy of ctx scoped to a tenant. Submit stamps new requests with it, and every read and every transition only sees that tenant's requests. Without a tenant the engine works in the empty tenant "", which suits single-tenant applications; a multi-tenant application that forgets to set one therefore sees nothing rather than everything. Applications that already keep the tenant in the context can set Options.TenantFrom instead.
Types ¶
type ApproverResolver ¶
type ApproverResolver interface {
HasRole(ctx context.Context, actor Ref, role, tenantID string) (bool, error)
}
ApproverResolver answers role membership questions for the application. Applications implement it when a workflow names roles.
HasRole reports whether actor holds role within tenantID, the request's tenant ("" for requests submitted without one). The engine calls it at the moment an actor decides, inside the decision's transaction, so a membership revoked while a request is in flight takes effect on the next decision; approvals already recorded stand. It must be safe for concurrent use and should honour ctx cancellation. An error aborts the operation that asked.
type CancelAuthorizer ¶
type CancelAuthorizer interface {
CanCancel(ctx context.Context, actor Ref, r Request) (bool, error)
}
CancelAuthorizer lets the application allow parties other than the requester, such as administrators, to cancel a request. Applications implement it; the requester may always cancel their own pending request.
CanCancel reports whether actor may cancel r, which is locked for the duration of the call and belongs to the caller's tenant. It must be safe for concurrent use. An error aborts the cancellation.
type CancelAuthorizerFunc ¶
CancelAuthorizerFunc adapts a function to CancelAuthorizer.
type DecideInput ¶
type DecideInput struct {
// Comment is stored with the decision; at most MaxCommentSize bytes.
Comment string
// Step, when set, must name the request's current step or the decision
// fails with ErrStaleStep. It protects a click meant for one step from
// counting for the next after the request moved on.
Step string
}
DecideInput carries the details of an approval or rejection.
type Decision ¶
type Decision struct {
ID uuid.UUID
RequestID uuid.UUID
Step int
Actor Ref
Outcome Outcome
Comment string
At time.Time
}
Decision is one actor's approval or rejection on one step of a request. Each actor decides at most once per request: once on a step, and never on a later step after approving an earlier one.
type DueQuery ¶
DueQuery pages through overdue requests for LockDue. When After is set only requests ordered after it by (DueAt, ID) are returned.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine runs workflows against a Store. It is safe for concurrent use; transitions of the same request are serialized by the store's row lock.
func New ¶
New returns an engine backed by store. It returns an error wrapping ErrInvalid when store is nil or a hook is nil.
func (*Engine) Approve ¶
Approve records an approval by the actor in ctx on the request's current step. Once the step's rule is satisfied the request advances, or is approved if it was the last step.
The actor must be eligible on the current step (listed, or holding one of its roles now), must not be the requester unless the workflow allows self-approval, and must not have decided on the request before: ErrAlreadyDecided reports a repeated decision on the same step and ErrNotEligible one on a later step after approving an earlier one, except on requests with PerStepDecisions (migrated from 0.1.0). A request of another tenant is ErrNotFound. A request whose deadline has passed is expired instead and ErrNotPending is returned.
Example (SeparationOfDuties) ¶
Separation of duties: the requester cannot approve their own request, and nobody approves two steps of the same request.
package main
import (
"context"
"fmt"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
func main() {
lee, sam, kim := approvals.Ref{Type: "user", ID: "lee"}, approvals.Ref{Type: "user", ID: "sam"}, approvals.Ref{Type: "user", ID: "kim"}
engine, _ := approvals.New(memstore.New(), approvals.Options{})
_ = engine.Register(approvals.Workflow{
Key: "refund",
Steps: []approvals.Step{
{Name: "lead", Approvers: []approvals.Ref{lee, sam}, Rule: approvals.RuleAny},
{Name: "audit", Approvers: []approvals.Ref{lee, sam, kim}, Rule: approvals.RuleAny},
},
})
req, _ := engine.Submit(approvals.WithActor(context.Background(), sam),
approvals.SubmitInput{Workflow: "refund", Subject: approvals.Ref{Type: "refund", ID: "r1"}})
_, err := engine.Approve(approvals.WithActor(context.Background(), sam), req.ID, approvals.DecideInput{})
fmt.Println(err)
req2, _ := engine.Submit(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ann"}),
approvals.SubmitInput{Workflow: "refund", Subject: approvals.Ref{Type: "refund", ID: "r2"}})
_, _ = engine.Approve(approvals.WithActor(context.Background(), lee), req2.ID, approvals.DecideInput{})
_, err = engine.Approve(approvals.WithActor(context.Background(), lee), req2.ID, approvals.DecideInput{})
fmt.Println(err)
}
Output: approvals: actor is not eligible: requesters cannot decide on their own requests approvals: actor is not eligible: user:lee already approved step 0 of this request
func (*Engine) Cancel ¶
Cancel withdraws a pending request. The actor in ctx must be the requester or be allowed by the CancelAuthorizer; otherwise ErrNotAuthorized is returned. reason, at most MaxCommentSize bytes, is recorded on the transition. A request of another tenant is ErrNotFound, a closed one ErrNotPending, and one whose deadline has passed is expired instead, with ErrNotPending, exactly as a decision would.
Example ¶
Cancel withdraws a request; parties other than the requester need the CancelAuthorizer's permission.
package main
import (
"context"
"fmt"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
func main() {
admins := approvals.CancelAuthorizerFunc(func(_ context.Context, actor approvals.Ref, _ approvals.Request) (bool, error) {
return actor == approvals.Ref{Type: "user", ID: "root"}, nil
})
engine, _ := approvals.New(memstore.New(), approvals.Options{CancelAuthorizer: admins})
_ = engine.Register(approvals.Workflow{
Key: "po",
Steps: []approvals.Step{{Name: "buyer", Approvers: []approvals.Ref{{Type: "user", ID: "bo"}}, Rule: approvals.RuleAny}},
})
as := func(id string) context.Context {
return approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: id})
}
req, _ := engine.Submit(as("ivan"), approvals.SubmitInput{Workflow: "po", Subject: approvals.Ref{Type: "po", ID: "7"}})
_, err := engine.Cancel(as("bo"), req.ID, "")
fmt.Println(err)
req, err = engine.Cancel(as("root"), req.ID, "duplicate order")
fmt.Println(req.Status, err)
}
Output: approvals: actor is not authorized: user:bo may not cancel this request cancelled <nil>
func (*Engine) ExpireDue ¶
ExpireDue expires every pending request whose deadline has passed and returns how many it expired. It is meant to be called periodically by a scheduler, from as many instances as convenient: each batch locks its requests and skips those another instance or a decision holds, so a request is expired exactly once. It works across all tenants regardless of ctx; hooks see a context carrying the request's tenant through WithTenant, and r.TenantID. A hook error holds back only that request (it stays pending for the next run); the errors are joined and returned after the remaining requests are processed. Cancelling ctx stops the sweep.
Example ¶
ExpireDue is meant for a periodic job; requests past their step deadline are expired and their hooks run.
package main
import (
"context"
"fmt"
"time"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
func main() {
now := time.Date(2026, 10, 23, 9, 0, 0, 0, time.UTC)
engine, _ := approvals.New(memstore.New(), approvals.Options{Now: func() time.Time { return now }})
_ = engine.Register(approvals.Workflow{
Key: "access",
Steps: []approvals.Step{{
Name: "owner", Approvers: []approvals.Ref{{Type: "user", ID: "olga"}}, Rule: approvals.RuleAny, Deadline: 24 * time.Hour,
}},
})
req, _ := engine.Submit(approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ivan"}),
approvals.SubmitInput{Workflow: "access", Subject: approvals.Ref{Type: "repo", ID: "billing"}})
now = now.Add(25 * time.Hour)
n, err := engine.ExpireDue(context.Background())
fmt.Println(n, err)
req, _ = engine.Get(context.Background(), req.ID)
fmt.Println(req.Status)
}
Output: 1 <nil> expired
func (*Engine) Get ¶
Get returns a request of the tenant in ctx. A request of another tenant is reported as ErrNotFound, like a missing one. No actor is required.
func (*Engine) History ¶
History returns a request's decisions and transitions, with the same tenant rule as Get. A request has at most one transition per step plus two, and one decision per eligible approver and step.
func (*Engine) PendingFor ¶
func (e *Engine) PendingFor(ctx context.Context, actor Ref, opts PageOptions) (RequestPage, error)
PendingFor returns one page of the requests, across all workflows of the tenant in ctx, that actor can decide on right now: those whose current step lists the actor or one of the actor's roles, excluding requests the actor already decided on, their own requests unless self-approval is allowed, and requests past their deadline. Requests are ordered by submission time, oldest first; pass the returned Next as opts.Cursor for the following page.
One pass through the pages is not a snapshot. A request whose submission commits late (inside a long caller transaction), or one that advances to a step the actor can decide, keeps its submission time, so it may sort before the cursor and be missed by a pass already under way; starting again from the first page shows it.
It returns ErrNoActor for a zero actor and ErrInvalid for a half-empty actor, a negative limit or a malformed cursor. Role eligibility is resolved by asking the ApproverResolver about each distinct role that the tenant's open requests are waiting on, so the cost grows with the number of such roles rather than with the number of requests.
Example ¶
PendingFor returns an actor's inbox one page at a time; Next is the cursor of the following page and is empty on the last one.
package main
import (
"context"
"fmt"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
func main() {
olga := approvals.Ref{Type: "user", ID: "olga"}
engine, _ := approvals.New(memstore.New(), approvals.Options{})
_ = engine.Register(approvals.Workflow{
Key: "access",
Steps: []approvals.Step{{Name: "owner", Approvers: []approvals.Ref{olga}, Rule: approvals.RuleAny}},
})
ivan := approvals.WithActor(context.Background(), approvals.Ref{Type: "user", ID: "ivan"})
for _, repo := range []string{"billing", "search", "web"} {
_, _ = engine.Submit(ivan, approvals.SubmitInput{Workflow: "access", Subject: approvals.Ref{Type: "repo", ID: repo}})
}
opts := approvals.PageOptions{Limit: 2}
for {
page, err := engine.PendingFor(context.Background(), olga, opts)
if err != nil {
panic(err)
}
for _, r := range page.Requests {
fmt.Println(r.Subject.ID)
}
if page.Next == "" {
break
}
opts.Cursor = page.Next
}
}
Output: billing search web
func (*Engine) Register ¶
Register validates w and makes it available to Submit. It returns an error wrapping ErrInvalidWorkflow for a malformed definition, including one no requester could ever complete, and ErrDuplicateWorkflow when the key is taken. The engine keeps its own copy of w.
func (*Engine) Reject ¶
Reject records a rejection by the actor in ctx, which closes the request. Eligibility, errors and deadlines are as for Approve.
type History ¶
type History struct {
Decisions []Decision
Transitions []Transition
}
History is the audit trail of a request, each list in the order it was recorded.
type Hook ¶
type Hook func(ctx context.Context, t Transition, r Request) error
Hook observes a transition. Hooks run inside the transaction that records the transition, after it is written, exactly once per transition, so they can enqueue notifications or update application rows atomically with it; r is a copy of the request as it stands after the transition. Returning an error aborts the whole transition.
Take the tenant from r.TenantID, not from the application's own context helpers: ExpireDue runs hooks for every tenant under the scheduler's context, so whatever the application's helpers read there is the scheduler's tenant. Engine calls made from a hook with its ctx (Get, History, PendingFor) resolve the request's tenant, whatever Options.TenantFrom is installed.
type Keyset ¶ added in v0.2.0
Keyset is a position in a list ordered by a time and then by ID, which makes the order unique even when times are equal. Stores page with it: a query with After set returns only rows ordered strictly after it.
type Options ¶
type Options struct {
// Resolver answers role membership. Workflows that use roles cannot be
// registered without one.
Resolver ApproverResolver
// CancelAuthorizer, when set, is consulted for cancellations by actors
// other than the requester.
CancelAuthorizer CancelAuthorizer
// Hooks run, in order, on every transition. They must not be nil.
Hooks []Hook
// Now returns the current time. Nil means time.Now.
Now func() time.Time
// ActorFrom returns the party performing the operation. Nil means the
// package-level ActorFrom, which reads the value stored by WithActor.
ActorFrom func(ctx context.Context) (Ref, bool)
// TenantFrom returns the tenant the operation belongs to. Nil means the
// package-level TenantFrom. When it reports no tenant, the engine
// works in the empty tenant "".
TenantFrom func(ctx context.Context) (string, bool)
}
Options configures an Engine. The zero value is valid for workflows that only name explicit approvers.
Example (ContextLookups) ¶
Options.ActorFrom and Options.TenantFrom let the engine read the actor and tenant the application already keeps in its context, so they are stated once for every package that needs them.
package main
import (
"context"
"fmt"
approvals "github.com/byteminus/go-approvals"
"github.com/byteminus/go-approvals/memstore"
)
type sessionKey struct{}
type session struct {
UserID, OrgID string
}
func main() {
engine, _ := approvals.New(memstore.New(), approvals.Options{
ActorFrom: func(ctx context.Context) (approvals.Ref, bool) {
s, ok := ctx.Value(sessionKey{}).(session)
return approvals.Ref{Type: "user", ID: s.UserID}, ok
},
TenantFrom: func(ctx context.Context) (string, bool) {
s, ok := ctx.Value(sessionKey{}).(session)
return s.OrgID, ok
},
})
_ = engine.Register(approvals.Workflow{
Key: "leave",
Steps: []approvals.Step{{Name: "manager", Approvers: []approvals.Ref{{Type: "user", ID: "max"}}, Rule: approvals.RuleAny}},
})
ctx := context.WithValue(context.Background(), sessionKey{}, session{UserID: "eve", OrgID: "acme"})
req, _ := engine.Submit(ctx, approvals.SubmitInput{Workflow: "leave", Subject: approvals.Ref{Type: "leave", ID: "l1"}})
fmt.Println(req.Requester, req.TenantID)
}
Output: user:eve acme
type PageOptions ¶ added in v0.2.0
type PageOptions struct {
// Limit is the page size: 0 means DefaultPageSize, and values above
// MaxPageSize are capped. A negative limit is rejected with ErrInvalid.
Limit int
// Cursor is the Next value of the previous page, or empty for the
// first page. It is opaque; a malformed cursor is rejected with
// ErrInvalid.
Cursor string
}
PageOptions selects one page of a list. The zero value is the first page of DefaultPageSize items.
type PendingQuery ¶
type PendingQuery struct {
TenantID string
Actor Ref
Roles []string
Now time.Time
After *Keyset
Limit int
}
PendingQuery selects one page of an actor's inbox. A request matches when it belongs to TenantID; it is pending and its DueAt is zero or after Now; Actor is not its requester, unless it allows self-approval; Actor has made no decision on it at all, so that nobody approves two steps (or, when the request has PerStepDecisions, none on its current step); and Actor is listed on its current step or Roles contains one of the current step's roles. When After is set, only requests ordered after it by (CreatedAt, ID) match.
type Ref ¶
Ref identifies a record or a party by type and ID, for example Ref{Type: "expense", ID: "exp_42"} for a subject or Ref{Type: "user", ID: "42"} for an actor. Typing every reference keeps "user 42" and "api_token 42" apart. A stored Ref is either zero or has both fields set; the engine rejects a half-empty Ref with ErrInvalid.
Example ¶
package main
import (
"fmt"
approvals "github.com/byteminus/go-approvals"
)
func main() {
r := approvals.Ref{Type: "expense", ID: "exp_42"}
fmt.Println(r, r.IsZero(), approvals.Ref{}.IsZero())
}
Output: expense:exp_42 false true
func ActorFrom ¶
ActorFrom returns the actor stored by WithActor. The boolean is false when none was set or the stored Ref is zero. A half-empty Ref is returned as set, so that the engine rejects it with ErrInvalid instead of treating it as missing.
type Request ¶
type Request struct {
ID uuid.UUID
TenantID string
Workflow string
Steps []Step
AllowSelfApproval bool
Subject Ref
Requester Ref
// PerStepDecisions marks a request submitted under 0.1.0, which let one
// approver decide on several steps of the same request. Such requests
// keep that rule, so that requests in flight during the upgrade can
// still complete; the upgrade SQL sets it on every existing row.
// Requests submitted now always have it false, and on them nobody
// approves twice.
PerStepDecisions bool
// Payload is optional application data, stored as JSON exactly as
// submitted, byte for byte. Nil means no payload.
Payload json.RawMessage
Status Status
// CurrentStep indexes Steps. Once the request is closed it keeps the
// index of the step that was open when it closed.
CurrentStep int
// DueAt is when the current step's deadline passes; zero means no
// deadline. It is always zero once the request is closed.
DueAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
Request is one run of a workflow. Steps and AllowSelfApproval are a snapshot taken at submission, so changing or removing a workflow in code never alters requests already in flight. Workflow, Steps, AllowSelfApproval, PerStepDecisions, Subject, Requester, Payload, TenantID and CreatedAt never change after submission; stores persist only Status, CurrentStep, DueAt and UpdatedAt on update.
type RequestPage ¶ added in v0.2.0
type RequestPage struct {
// Requests holds at most the requested number of requests.
Requests []Request
// Next is the cursor of the following page, or empty when this page is
// the last.
Next string
}
RequestPage is one page of requests.
type ResolverFunc ¶
ResolverFunc adapts a function to ApproverResolver.
Example ¶
package main
import (
"context"
"fmt"
approvals "github.com/byteminus/go-approvals"
)
func main() {
admins := approvals.ResolverFunc(func(_ context.Context, actor approvals.Ref, role, _ string) (bool, error) {
return role == "admin" && actor == approvals.Ref{Type: "user", ID: "root"}, nil
})
ok, _ := admins.HasRole(context.Background(), approvals.Ref{Type: "user", ID: "root"}, "admin", "")
fmt.Println(ok)
}
Output: true
type Rule ¶
type Rule string
Rule decides when a step has collected enough approvals.
const ( // RuleAny completes the step on the first eligible approval. RuleAny Rule = "any" // RuleAll completes the step once every explicit approver has approved. // Steps using it may not list roles, because role membership is open // ended and "everyone in a role" cannot be counted reliably. RuleAll Rule = "all" // RuleQuorum completes the step once Step.Quorum distinct eligible // actors have approved. RuleQuorum Rule = "quorum" )
type Status ¶
type Status string
Status is the lifecycle state of a request.
const ( // StatusPending means the request is waiting for decisions. StatusPending Status = "pending" // StatusApproved means every step was satisfied. StatusApproved Status = "approved" // StatusRejected means an eligible actor rejected the request. StatusRejected Status = "rejected" // StatusCancelled means the requester or an authorized actor withdrew it. StatusCancelled Status = "cancelled" // StatusExpired means the current step's deadline passed. StatusExpired Status = "expired" )
type Step ¶
type Step struct {
// Name identifies the step within its workflow; names are unique per
// workflow and let callers guard decisions against a step that has
// already moved on (see DecideInput.Step).
Name string
// Approvers lists the parties that may decide on this step.
Approvers []Ref
// Roles lists roles whose members may decide on this step.
Roles []string
// Rule decides when the step is satisfied.
Rule Rule
// Quorum is the number of approvals RuleQuorum requires. It must be zero
// for the other rules.
Quorum int
// Deadline, when positive, is how long the step may stay open. Once it
// passes the request expires. The resulting due time is truncated to
// the microsecond, PostgreSQL's precision.
Deadline time.Duration
}
Step is one stage of a workflow. An actor is eligible to decide on the step when it is listed in Approvers or holds one of Roles, as reported by the engine's ApproverResolver at the moment it decides.
type Store ¶
type Store interface {
// InTx runs fn in a transaction. The context passed to fn carries the
// transaction: store calls made with it join the transaction, and so do
// hooks. If fn returns an error the transaction's writes are discarded.
// A nested InTx joins the outer transaction and, when it fails, discards
// only its own writes.
InTx(ctx context.Context, fn func(ctx context.Context) error) error
// CreateRequest inserts r. It returns ErrConflict when r.ID exists and
// ErrInvalid when r is malformed: empty workflow; incomplete subject or
// requester; no steps; an unknown status; CurrentStep out of range; a
// closed request with a DueAt; a payload that is not valid JSON; or
// text PostgreSQL cannot store (invalid UTF-8, NUL bytes).
CreateRequest(ctx context.Context, r Request) error
// GetRequest returns the request with id in tenantID, or ErrNotFound.
GetRequest(ctx context.Context, tenantID string, id uuid.UUID) (Request, error)
// LockRequest returns the request like GetRequest and locks it until the
// surrounding InTx ends, so that concurrent writers on the same request
// are serialized. A second LockRequest on the same request from another
// transaction waits for the first to end and then sees its writes.
LockRequest(ctx context.Context, tenantID string, id uuid.UUID) (Request, error)
// UpdateRequest saves the mutable fields of r: Status, CurrentStep, DueAt
// and UpdatedAt; every other field, the payload included, is ignored.
// Only pending requests may change: it returns ErrNotFound when r.ID
// does not exist in r.TenantID, ErrNotPending when the stored request
// is closed, and ErrInvalid when the new state is malformed.
UpdateRequest(ctx context.Context, r Request) error
// CreateDecision inserts d. It returns ErrAlreadyDecided when the actor
// already decided on that step of that request, ErrConflict when d.ID
// exists, ErrNotFound when the request does not exist, and ErrInvalid
// when d is malformed (negative step, incomplete actor, unknown
// outcome).
CreateDecision(ctx context.Context, d Decision) error
// ListDecisions returns the decisions on a request of tenantID in
// insertion order, or none when there is no such request.
ListDecisions(ctx context.Context, tenantID string, requestID uuid.UUID) ([]Decision, error)
// CreateTransition inserts t, with the same error rules as
// CreateDecision apart from ErrAlreadyDecided. Its Actor may be zero.
CreateTransition(ctx context.Context, t Transition) error
// ListTransitions returns the transitions of a request of tenantID in
// insertion order, or none when there is no such request.
ListTransitions(ctx context.Context, tenantID string, requestID uuid.UUID) ([]Transition, error)
// ListPending returns one page of an inbox; see PendingQuery for the
// matching rules. Requests are ordered by CreatedAt then ID, and at
// most q.Limit are returned. It returns ErrInvalid when q.Limit is not
// positive.
ListPending(ctx context.Context, q PendingQuery) ([]Request, error)
// ListPendingRoles returns the distinct roles named by the current
// steps of the pending, not yet overdue requests of q.TenantID, in
// bytewise order.
ListPendingRoles(ctx context.Context, q RoleQuery) ([]string, error)
// LockDue returns, across all tenants, pending requests whose DueAt is
// set and not after q.Now, ordered by DueAt then ID, at most q.Limit of
// them, and locks them like LockRequest. It must be called inside InTx.
// Requests another transaction holds locked are skipped rather than
// waited for, so that several sweepers never block one another. It
// returns ErrInvalid when q.Limit is not positive.
LockDue(ctx context.Context, q DueQuery) ([]Request, error)
}
Store persists requests, decisions and transitions. Applications pick one of the stores in this module (memstore, pgxstore) or implement their own and verify it with the storetest conformance suite.
Implementations must be safe for concurrent use and must enforce the constraints documented on each method, so that every store behaves the same. Every method that reads or changes a request takes its tenant and treats a request of another tenant exactly like a missing one. Times are returned in UTC; a store may truncate them to its precision, but never below a microsecond. Errors are the package sentinels, wrapped: ErrNotFound, ErrInvalid for a record that violates the constraints, ErrConflict for a duplicate ID, and the narrower errors named below.
type SubmitInput ¶
type SubmitInput struct {
// Workflow is the key of a registered workflow.
Workflow string
// Subject identifies the business object being approved. Both fields
// are required, each at most 200 bytes.
Subject Ref
// Payload is optional JSON the application wants kept with the
// request: at most MaxPayloadSize bytes and MaxPayloadDepth levels of
// nesting. It is stored as given and never changes afterwards.
Payload []byte
}
SubmitInput describes a new request.
type Transition ¶
type Transition struct {
ID uuid.UUID
RequestID uuid.UUID
Kind TransitionKind
FromStep int
ToStep int
Actor Ref
Comment string
At time.Time
}
Transition is an entry in a request's history describing a state change. FromStep and ToStep are the step indexes before and after the change; they differ only for TransitionStepAdvanced. Actor is the party that caused it: the requester for a submission, the decider for a decision, whoever cancelled for a cancellation, and zero for an expiry, which the engine makes on its own.
type TransitionKind ¶
type TransitionKind string
TransitionKind names a change in a request's state.
const ( // TransitionSubmitted records the creation of a request. TransitionSubmitted TransitionKind = "submitted" // TransitionStepAdvanced records a satisfied step that was not the last. TransitionStepAdvanced TransitionKind = "step_advanced" // TransitionApproved records the last step being satisfied. TransitionApproved TransitionKind = "approved" // TransitionRejected records a rejection that closed the request. TransitionRejected TransitionKind = "rejected" // TransitionCancelled records a cancellation. TransitionCancelled TransitionKind = "cancelled" // TransitionExpired records a missed deadline. TransitionExpired TransitionKind = "expired" )
func (TransitionKind) Valid ¶
func (k TransitionKind) Valid() bool
Valid reports whether k is one of the defined transition kinds.
type Workflow ¶
type Workflow struct {
// Key identifies the workflow, for example "expense".
Key string
// Steps run in order; the request is approved when the last one is.
Steps []Step
// AllowSelfApproval lets the requester decide on their own request. It is
// off by default to enforce separation of duties. It never lets anyone,
// requester or not, approve more than one step of the same request.
AllowSelfApproval bool
}
Workflow is a named, ordered list of steps defined in application code and registered with an Engine.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore is an in-memory approvals.Store for tests, examples and prototypes.
|
Package memstore is an in-memory approvals.Store for tests, examples and prototypes. |
|
Package pgxstore is the PostgreSQL implementation of approvals.Store, built on github.com/jackc/pgx/v5.
|
Package pgxstore is the PostgreSQL implementation of approvals.Store, built on github.com/jackc/pgx/v5. |
|
Package storetest is a conformance suite for approvals.Store implementations.
|
Package storetest is a conformance suite for approvals.Store implementations. |