Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The service facade

The service package is the thin decision facade every surface calls instead of touching the engine directly — the CLI check command, the HTTP /check endpoint, the Twirp service, and the MCP read subset. It exists so those surfaces share one code path with one fail-closed policy: the rule for turning an engine error into a rendered decision lives here, not duplicated per surface.

If you are embedding Aperture to build your own surface, call the facade rather than the raw engine — you inherit its fail-closed contract, decision auditing, and the what-if Simulate path for free.

Constructing the facade

func New(eng *engine.Engine, opts ...Option) *Service

With no options a Service is read-only: it carries the decision API (Check / Enumerate / Explain and their batch forms) always, and returns APERTURE_UNIMPLEMENTED from any mutation. Options wire the additional dependencies:

OptionEnables
WithStorage(store model.Storage)Entity-CRUD mutations and their reads; also the base store the Simulate overlay layers onto.
WithGate(gate *authz.Gate)The admin-authority gate consulted before every system/account-tier mutation.
WithDelegation(d *delegation.Service)Bestow / Revoke.
WithImpersonation(i *impersonation.Service)ImpersonationStart / session issuance.
WithAudit(r *audit.Recorder)The append-only audit trail: mutations synchronously, decision checks sampled + async.
WithProviders(reg *provider.Registry)ObjectIdentifiers and ObjectMetadata — object enumeration and metadata reads.
WithRuleSource(base rules.RuleSource, fetcher rules.MetadataFetcher)The what-if preview of an unsaved rule via Simulate’s Overlay.Rules.
WithClock(now func() time.Time)Override the facade clock used to stamp entity timestamps on writes (for deterministic tests).

The serve command builds the fully-wired facade so HTTP, Twirp, and the CLI all drive one mutation path. A decision-only surface can stay minimal with service.New(eng).

Surface-neutral query types

The facade takes Query / EnumerateQuery — surface-neutral mirrors of the engine’s request types — so the CLI and HTTP layers marshal to and from these and the engine’s Request stays an engine-internal concern.

type Query struct {
	Account   string
	Principal string
	Action    string
	Object    string
}

type Result struct {
	Allow            bool     // the verdict
	Reason           string   // names the deciding grants, or the fail-closed cause
	DecidingGrantIDs []string // empty on a default-deny or a fail-closed deny
}

type EnumerateQuery struct {
	Account   string
	Principal string
	Action    string
	Pattern   string
	Limit     int
}

Fail-closed rendering

The facade’s reason for existing is one shared policy for turning an engine outcome into a rendered decision:

Engine outcomeFacade renders it as
A clean decisionPasses through unchanged.
An input-validation error (APERTURE_INVALID_INPUT / APERTURE_IDENTITY_INVALID)Returned to the caller verbatim — the caller asked an ill-formed question, so the CLI renders a usage error and HTTP returns 400. Not a deny.
Any other engine error (unknown principal, storage fault, …)Folded fail-closed into a deny Result (Allow: false, cause in Reason, Err nil). A decision point must never fail open.

This rule is applied per operation as follows.

Check (fail-closed)

func (s *Service) Check(ctx context.Context, q Query) (Result, error)

Check returns an error only for a genuine input-validation failure; every other engine failure folds into a fail-closed deny Result with a nil error. On a clean render the decision is audited (sampled, asynchronous, off the hot path).

res, err := svc.Check(ctx, service.Query{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Object:    "account:acme/project:atlas/document:42",
})
if err != nil {
	// only a malformed query reaches here (APERTURE_INVALID_INPUT / _IDENTITY_INVALID)
	return err
}
fmt.Println(res.Allow, res.Reason) // an operational failure is res.Allow == false, err == nil

Enumerate and Explain (verbatim errors)

func (s *Service) Enumerate(ctx context.Context, q EnumerateQuery) ([]string, error)
func (s *Service) Explain(ctx context.Context, q Query) (engine.Trace, error)

Enumerate and Explain return engine errors verbatim for the surface to map to a status. Enumerate cannot fail open by construction — every id it returns is one Check allows — so an operational failure is a returned error, not a silent partial set. Explain is a diagnostic, not an enforcement gate; its engine.Trace is the public contract surfaces serialize.

Batch forms

CheckBatch, EnumerateBatch, and ExplainBatch return per-item engine.BatchResult[T] aligned with their queries. CheckBatch renders each item exactly as Check (operational error → deny Result; input-validation error → item Err); the other two carry engine errors verbatim per item. See Batch operations.

Simulate — what-if

The facade adds a read-only what-if surface: Simulate and SimulateExplain answer “what would the decision be if these hypothetical entities existed?” without ever persisting them. It is the seam the MCP Simulate tool and the what-if simulator UI drive.

func (s *Service) Simulate(ctx context.Context, ov Overlay, q Query) (Result, error)
func (s *Service) SimulateExplain(ctx context.Context, ov Overlay, q Query) (engine.Trace, error)

Both require the entity surface (WithStorage) so there is a base store to overlay. Simulate carries the same fail-closed contract as Check; SimulateExplain returns the trace verbatim like Explain. Nothing is written and nothing is audited — a simulation is not a real decision.

The overlay

Overlay is the set of hypothetical entities a run layers over the live model. Every field is additive and optional; an overlay entity with the same id as a stored one shadows it (so a what-if can model an edited grant or a re-roled principal), and ids absent from the overlay fall through to storage.

type Overlay struct {
	Principals  []model.Principal  // hypothetical or shadowing principals
	Groups      []model.Group      // hypothetical groups (union with stored memberships)
	Permissions []model.Permission // hypothetical or shadowing permissions
	Grants      []model.Grant      // the hypothetical grants — the common what-if input
	Memberships []model.Membership // hypothetical account memberships (consulted under enforcement)
	Rules       []model.Rule       // an unsaved rule being previewed (needs WithRuleSource)
}

The mechanism is structural, not conventional: Simulate builds a transient engine (e.WithStore(overlay) — same coverer, membership policy, and clock as the live engine, just a different read source) whose overlay store’s writes are all inert. A simulation physically cannot persist through it.

Worked example: “what if I bestowed this grant?”

Suppose bob currently cannot read document:42, and you want to preview the effect of a new allow grant before bestowing it. Layer the hypothetical grant (and the permission it references, if not already stored) into an Overlay and ask SimulateExplain — the trace shows which hypothetical grant decided the verdict.

import (
	"github.com/frankbardon/aperture/model"
	"github.com/frankbardon/aperture/service"
)

ov := service.Overlay{
	Grants: []model.Grant{{
		ID:           "sim-grant-1",
		AccountID:    "acme",
		Subject:      model.Subject{Kind: model.SubjectPrincipal, ID: "bob"},
		PermissionID: "perm-doc-read",
		Effect:       model.EffectAllow,
		Object:       "account:acme/project:atlas/**",
	}},
}

tr, err := svc.SimulateExplain(ctx, ov, service.Query{
	Account:   "acme",
	Principal: "bob",
	Action:    "read",
	Object:    "account:acme/project:atlas/document:42",
})
if err != nil {
	return err
}
fmt.Print(tr.String()) // shows sim-grant-1 as the deciding grant — nothing was written

Because Simulate reuses the engine’s exact resolution, a hypothetical deny overlay grant correctly carves out a stored allow, and a shadowing principal models “what if alice had role X” — all without a write.

Two adjacent reads support the rule-builder’s what-if and require WithProviders:

  • ObjectMetadata(ctx, objectID) (map[string]any, error) — the provider metadata a rule preview evaluates against.
  • EvaluateRule(ctx, ast *rules.Node, objectID) (bool, map[string]any, error) — compiles an unsaved rule AST and evaluates it against one object’s metadata, returning the boolean result and the metadata snapshot it saw.

ObjectIdentifiers(ctx, objectType, exclude...) (also WithProviders) enumerates a type’s complete instance set minus any excluded ids — the positive allow-list an exclusive allowance materialises to.