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

Decision API

The engine package is Aperture's Policy Decision Point. It exposes three single operations — Check, Enumerate, and Explain — on *engine.Engine. This page gives their real signatures, input and result shapes, and the rule for choosing between them. Their bulk and impersonation-aware forms live in Batch operations and Impersonation.

All three are methods on an engine you construct once and reuse. The engine is stateless beyond its storage handle and safe for concurrent use to whatever degree the underlying Storage is.

Constructing the engine

func New(store model.Storage, opts ...Option) *Engine

With no options the engine uses the literal identity-pattern coverer — a grant's object pattern is matched directly against the requested object. The options extend that behaviour:

OptionEffect
WithScopeResolution(registry *scope.Registry, deps ...ScopeDeps)Consult each grant's pluggable scope resolver (selected by its permission's scope-strategy) for object membership, instead of only literal pattern matching. A nil registry uses scope.DefaultRegistry().
WithMembershipEnforcement()Require the request's principal to be a member of the active account before any grant is consulted. A non-member is denied at the door (a fail-closed default-deny), rather than erroring. Off by default.
WithMetadata(f MetadataFetcher)Supply the object-metadata source Enumerate's Fields filter reads through — normally the same *provider.Registry wired as the scope lister. Consulted only by a request that carries Fields.
WithReferences(r ReferenceSource)Supply the declared-reference source Enumerate's reference edges are dereferenced through — normally the same *provider.Registry again. Consulted only by a request that carries References; an engine wired without it fails loudly for one that does, never with an empty result.
WithLogger(l *slog.Logger)The sink for non-fatal operational findings — today only a skipped dangling reference, which is invisible to an operator otherwise. Nil, or unset, means slog.Default(). It is not a decision log: nothing on the Check hot path logs.
WithClock(now func() time.Time)Override the engine clock. It governs impersonation time-box expiry only; the non-impersonated path never reads it. Production uses time.Now.

Two further seams return a shallow copy of the engine rather than mutating it, for the read-only what-if paths: (*Engine).WithStore(store) re-points the copy at a different (e.g. overlay) store, and (*Engine).WithRuleEvaluator(re) redirects rule-backed scope strategies at a different rule evaluator. Both leave the original engine untouched, so a live engine and a transient what-if engine never interfere. The facade's Simulate path is built on exactly these.

Check

func (e *Engine) Check(ctx context.Context, req Request) (Decision, error)

Check resolves a single authorization decision: may this principal take this action on this one object, in this account?

Request

Request is a value type; every field is mandatory.

type Request struct {
	Account   string // active account the decision is scoped to
	Principal string // id of the principal requesting access
	Action    string // the verb being attempted, e.g. "read"
	Object    string // canonical object-identity string
}

Principal is a principal id (the key storage and the subject set are keyed on), not the principal's identity string. Object is a canonical object-identity string such as account:acme/project:atlas/document:42. Grants stamped to any account other than Account are never consulted (the sole exception is a grant stamped to the account wildcard *, which spans all tenancies).

Decision

type Decision struct {
	Allow            bool                  // the verdict: true permits, false denies
	Reason           string                // human-readable explanation naming the deciding grant(s)
	DecidingGrantIDs []string              // ids of the grant(s) that produced the verdict, sorted; empty on a default-deny
	Impersonation    *ImpersonationContext // non-nil only under an active impersonation session (see Impersonation)
}

Reason names the deciding grant(s), their specificity, and how many grants were considered. DecidingGrantIDs is sorted for determinism and is empty on a default-deny. Impersonation is nil on the ordinary path.

Error contract

Check never returns an allow-on-error. Any operational failure — a malformed request, an unknown principal, a storage fault — is returned as an APERTURE_* coded error and the caller treats it as a non-decision. A well-formed request that simply matches no grant is a clean default-deny (Allow: false, no error). Default-deny is the floor: with no candidate grant the answer is DENY.

dec, err := eng.Check(ctx, engine.Request{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Object:    "account:acme/project:atlas/document:42",
})
if err != nil {
	// operational failure — not a decision
	return err
}
fmt.Println(dec.Allow, dec.Reason)

Building your own surface? Prefer the facade's Service.Check, which folds operational errors into a fail-closed deny so a decision point can never fail open. The raw engine.Check here returns those errors for the facade to render.

Enumerate

func (e *Engine) Enumerate(ctx context.Context, req EnumerateRequest) ([]string, error)

Enumerate is the inverse of Check: it returns the object ids under a pattern that the principal may take the action on, in the active account. Every id it returns is one Check would allow — a denied object is never returned — so the two operations agree by construction.

EnumerateRequest

type EnumerateRequest struct {
	Account    string          // active account the enumeration is scoped to
	Principal  string          // id of the principal whose access is enumerated
	Action     string          // the verb being enumerated, e.g. "read"
	Pattern    string          // identity pattern bounding the search
	Fields     map[string]any  // optional object-metadata predicates; nil/empty filters nothing
	References []ReferenceEdge // optional reference edges; nil/empty restricts nothing
	Limit      int             // caps the number of returned ids; <= 0 means the default bound
}

Pattern both bounds the candidate set and is intersected with each grant's own scope — for example account:acme/** (everything in the account) or account:acme/document:* (every document at the account root). Limit caps the result; a non-positive Limit (or one above the default) is clamped to engine.DefaultEnumerateLimit (1000), so an enumeration can never materialise an unbounded set. Object order is deterministic (sorted by canonical id).

ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Pattern:   "account:acme/project:atlas/**",
	Limit:     100,
})

An operational failure — a storage fault, an unresolvable scope strategy, or an unconfigured object lister an implicit/exclusive grant needs — is returned as a coded error, never a silent partial set.

Filtering by object metadata

Fields is an optional set of object-metadata predicates. An allowed candidate is returned only when its metadata satisfies all of them; a nil or empty map (the default) filters nothing and never touches a metadata source, so an unfiltered enumeration costs exactly what it always did.

ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Pattern:   "account:acme/**",
	Fields:    map[string]any{"tier": "premium", "brands": "brand:Y"},
	Limit:     10,
})

The meaning is provider.Filter's Fields contract verbatim, evaluated by the same provider.MatchFields a provider's own Query calls — so an enumeration filtered here and one filtered inside a provider select the same objects. AND across keys; a collection field matches by membership; an absent field never matches, not even a nil want; and comparison is typed (int64(5) matches a float64(5) want, "5" does not match 5).

Two orderings are load-bearing:

  1. Deny first. The predicate runs on candidates that already survived deny-overrides and specificity, so it can only subtract from the allowed set. A denied object is never returned, whatever the predicate says.
  2. Filter before Limit. The candidate set is predicated before it is truncated, so asking for the first 10 objects tagged brand:Y searches every candidate rather than tagging the first 10 candidates and returning the few that stuck.

Metadata is read through engine.WithMetadata(fetcher), whose seam (MetadataFetcher) has the signature of *provider.Registry.Fetch — the same registry that backs the scope lister and the rule evaluator, so a candidate is served from the per-type cache the enumeration already warmed:

eng := engine.New(store,
	engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
	engine.WithMetadata(reg))

Failure is deliberately asymmetric — an enumeration returning fewer objects reads as "no access", one returning more is an authorization bug:

  • no metadata source wired, or no provider for the candidate's object-typeAPERTURE_PROVIDER_UNREGISTERED, never a silently empty result. The predicate runs per candidate, so this only surfaces once the enumeration has at least one allowed candidate; an empty allowed set returns empty regardless of wiring.
  • the object has no metadata row (APERTURE_NOT_FOUND from Fetch) → every field is absent, absent never matches, so the object is excluded.
  • any other provider failure → returned verbatim.

EnumerateBatch and EnumerateAs carry Fields through the same path.

Restricting through a declared reference

References restricts the enumeration to the identities a holder object's declared reference field contains — "the brands in dataset X". Nil or empty (the default) restricts nothing.

ids, err := eng.Enumerate(ctx, engine.EnumerateRequest{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Pattern:   "account:acme/brand:*",
	References: []engine.ReferenceEdge{{
		HolderID: "account:acme/dataset:x", // HolderType is optional
		Field:    "current_brands",         // must be a DECLARED reference
	}},
})

It is a dereference, not a filter, and the distinction is the whole reason it exists. Fields answers "which datasets contain brand Y?" because the dataset holds current_brands. A brand holds no field naming its datasets — references are declared on the holding side only — so no predicate on brand can express "which brands belong to dataset X?" at all.

Composition mirrors the filter's: several edges AND, an edge composes with Fields, both apply before Limit, and the restriction can only subtract from the allowed set. Exactly one hop is taken — the identities an edge yields are never themselves dereferenced. The whole restriction is resolved once per enumeration, before candidates are gathered, against the same grants and subject set the candidates are decided with; that is what makes EnumerateAs check the holder with the impersonated authority.

The failure modes are asymmetric, and the asymmetry is the security model:

SituationResult
The principal may not read the holderEmpty result, no error. "You may not see dataset X" and "dataset X contains nothing you may see" have to be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about.
The holder is absent, inside Account, caller is a memberAPERTURE_NOT_FOUND — the ergonomics a typo deserves, confined to a caller already inside the account. Existence is resolved before the holder's check precisely so this answer survives.
The holder is outside AccountEmpty, whether or not it exists. The disclosure boundary.
The caller is not a memberEmpty, always — membership is decided before the holder is looked up.
A referenced identity no longer existsSkipped, with a warning log naming it and a dangling_reference note that does not. An application-level foreign key has no database constraint behind it, so one deleted brand must not fail every decision on the dataset that still lists it.
The field is not declared, the holder type has no provider, or no reference source is wiredAPERTURE_PROVIDER_REFERENCE_INVALID / APERTURE_PROVIDER_UNREGISTERED — loud, never an empty list. A wiring fault describes the deployment, not the data.
A reference value does not point at the declared targetAPERTURE_PROVIDER_REFERENCE_MISMATCH — never a silently dropped value.

Wire the source alongside the metadata source; it is the same registry:

eng := engine.New(store,
	engine.WithScopeResolution(nil, engine.ScopeDeps{Lister: reg, Rules: rulesEngine}),
	engine.WithMetadata(reg),
	engine.WithReferences(reg),
	engine.WithLogger(logger))

A rules-engine dereference is deliberately not supported: Check owes a p99 under a millisecond, and following a reference inside rule evaluation is a join on the decision hot path with a recursive cache-miss path behind it. Enumeration computes the restriction once, off that path.

EnumerateBatch and EnumerateAs carry References through the same path.

Explain

func (e *Engine) Explain(ctx context.Context, req Request) (Trace, error)

Explain resolves the request exactly as Check does but records the full derivation instead of only the verdict. Use it as a diagnostic — the "why" behind a verdict — not as an enforcement gate. It takes the same Request as Check.

Trace

Trace is a stable public contract: the RPC surface, the MCP inspect tool, and the what-if simulator all serialize it, so its fields are part of the API.

type Trace struct {
	Request        Request               // the question that was asked
	Subjects       []model.Subject       // the principal's expanded subject set (itself, roles, groups)
	Considered     []GrantEvaluation     // every grant loaded, each tagged with how it fared
	MaxSpecificity int                   // top specificity among covering candidates; 0 when nothing covered
	Notes          []EvaluationNote      // diagnostics rule evaluation recorded; empty when no rule ran
	Attributes     TraceAttributes       // the `principal` and `account` bags the rules read; zero when no rule ran
	Now            time.Time             // the reference instant rule evaluation resolved against; zero when no rule ran
	Decision       Decision              // the final verdict — identical to what Check returns
	Impersonation  *ImpersonationContext // non-nil only under an active impersonation session
}

Each entry in Considered is a GrantEvaluation recording one grant's contribution — its subject, permission, effect, object pattern, whether its action matched, whether it covered the object and at what specificity, which scope strategy it used, whether it was a deciding grant, and a short human-readable Outcome. A grant that failed the action match is still listed (with ActionMatched: false) so the trace shows what was ruled out.

Evaluation notes

Notes is where a rule-backed scope explains itself. A rule can decide false for a reason the verdict never shows — the metadata field it reads is the wrong shape (a string where an array was meant), or it matched only because the field is absent. Both are deny-safe by policy: a collection operator over a non-collection evaluates to false rather than raising APERTURE_RULE_EVAL, so one mistyped field cannot break every decision that touches it. See Rules.

type EvaluationNote struct {
	GrantID  string // the grant whose scope resolution recorded it
	Rule     string // the rule reference that was evaluated
	Kind     string // "shape_mismatch" | "absent_field" | "date_invalid" |
	                //   "date_bounds_inverted" | "dangling_reference" | "attributes_floor_only"
	Op       string // the comparison operator ("hasAll", …)
	Path     string // the dotted variable path ("object.tags")
	Expected string // the shape the operator requires ("collection")
	Actual   string // the shape found ("string")
	Message  string // "object.tags: expected collection, got string"
}

Six kinds are recorded today: shape_mismatch, absent_field, date_invalid, date_bounds_inverted, dangling_reference (an enumeration skipped a declared reference whose target no longer exists), and attributes_floor_only (a rule read a host-defined field off principal or account while that root carried nothing but the engine's floor, so every such comparison was false).

Three rules govern them:

  1. Diagnostic only — a note never influences a verdict.
  2. Explain onlyCheck and Enumerate install no collector, so they record nothing, allocate nothing, and behave exactly as before.
  3. Shape and path only — never a metadata value, never anything that could cross an account boundary, the same discipline error messages follow.

The attribute bags

Attributes carries the principal and account roots this decision's rules were evaluated against — the host's bag with the engine's floor stamped over it, exactly as a rule read it:

type TraceAttributes struct {
	Principal map[string]any `json:"principal,omitempty"`
	Account   map[string]any `json:"account,omitempty"`
}

Both are nil on a decision that evaluated no rule, and Account is also nil for a decision made at the account wildcard "*" — that is not an account, so no bag is resolved for it and none is invented here.

This field deliberately discloses VALUES, and it is the one place a Trace does. It is not an oversight to fix, and it does not contradict rule 3 above: a note is produced by a comparison against an object's metadata, on whatever objects a wide decision happened to sweep, while these two bags are the subjects of the very question being asked — the principal in Request.Principal and the account in Request.Account. A trace that discloses them tells the asker about their own decision and nothing else.

The disclosure is also the point: "why was this denied?" is unanswerable from a grant list when the deciding comparison was principal.tier == "gold" and the operator cannot see that the tier is "silver", or absent entirely. So do not redact these into a note, and do not widen them past those two subjects. The wider-audience rule what-if preview takes the opposite side on purpose and supplies no principal or account input at all, so a rule author's editor cannot become a directory read oracle.

Trace implements String(), which renders an operator-readable, deterministic report:

tr, err := eng.Explain(ctx, engine.Request{
	Account:   "acme",
	Principal: "alice",
	Action:    "read",
	Object:    "account:acme/project:atlas/document:42",
})
if err != nil {
	return err
}
fmt.Print(tr.String())

tr.Decision is byte-for-byte the decision Check returns for the same request, so a surface can render a verdict and its explanation from a single Explain call. The rendered report prints each non-empty attribute root as one key-sorted line before the grants — they are the input the grants' rules were judged against — and stays byte-identical for the same decision.

When to use each

QuestionOperation
"May this principal do this one thing?" — an enforcement gate on the hot path.Check
"Which of these objects may this principal act on?" — building a filtered listing or a picker.Enumerate
"Why did that decision come out the way it did?" — a diagnostic, an audit view, a support tool.Explain

Check is the allocation-conscious hot path; reach for it in enforcement. Enumerate is the most cache-sensitive op and is deliberately bounded — use it to answer "what can they see", not as a substitute for repeated Checks on a known object. Explain does the same work as Check plus recording the derivation, so use it when a human (or a machine) needs to understand the verdict, not on every hot-path call.