Introduction
Aperture is a fine-grained access-control engine for the frankbardon/*
family of services. It answers one question — “is this principal allowed to do
this thing to this resource, and why?” — consistently across every surface that
asks it.
Library-first
Aperture is library-first: the public Go packages at the module root
(github.com/frankbardon/aperture) are the product. Every other surface — the
aperture CLI, the Twirp/HTTP RPC API, the MCP server, and the admin UI — is a
thin translator over a single decision engine. There is exactly one place a
decision is made, so the answer a script gets from the CLI is the same answer a
service gets over RPC and an agent gets over MCP.
The decision API
The engine exposes three operations, each available in a single and a bulk-batched form:
| Operation | Question it answers |
|---|---|
| Check | May this principal perform this action on this resource? |
| Enumerate | Which resources/actions is this principal allowed? |
| Explain | Why was a decision reached — which rules and grants applied? |
Explain is a first-class citizen, not a debugging afterthought: access
decisions are auditable by construction.
What’s inside
Aperture models principals (identity), the resource/object model (model,
scope), and object providers (provider) that resolve live attributes.
Decisions flow through the engine, driven by a rules layer (rules) that
compiles a rule AST to an expr-lang/expr
expression and evaluates it in-process — pure-Go, no external policy service.
Grants come in several flavors (direct, delegation, impersonation), reads
are narrowed by scoped visibility (filter), and every decision can be recorded
to an audit log (audit). Persistence sits behind one Storage interface with
a hand-written SQL / modernc.org/sqlite implementation and an in-memory twin.
Design tenets
- Pure-Go,
CGO_ENABLED=0end to end. No CGO, no external policy engine. - Coded errors. Every failure is an
APERTURE_*error carrying a stable code and an actionable fixup — never a bare string. - No cross-account leakage. Decisions and error messages never expose data from an account the caller cannot see.
- One engine, many surfaces. CLI, RPC, MCP, and UI are adapters; the decision logic lives once, in the library.
Where to go next
- Getting Started — install Aperture and run your first
check. - CLI & Library — the
aperturecommand tree and the Go embedding API. - Service Surfaces — the Twirp/HTTP RPC API, MCP server, and admin UI.
- Concepts — identities, the object model, scopes, rules, grants, and audit.
- Reference — error codes, configuration, and the CLI/RPC surface tables.
- Operations — deployment, storage, and running the engine in production.
- Internals — architecture, package layout, and extension points.
- Contributing — conventions and the Update-Demand rule.
Getting Started
This section is the on-ramp. In four short pages you install Aperture, learn the vocabulary the rest of the book assumes, and make your first access-control decision two ways — from the command line and from Go.
Everything here runs against Aperture’s embedded example model, a
self-contained fixture for the tenant acme. You need nothing but the built
binary; there is no store to provision and no data to load.
Read in order
- Installation — build
bin/aperturefrom source (Go 1.26.1, pure-Go,CGO_ENABLED=0). - Concepts primer — principals, actions, objects, patterns and specificity, rules, scopes, and accounts: enough to read any later chapter.
- First decision (CLI) — a runnable
aperture checkwalkthrough showing an allow, a default deny, and a deny-override. - Library quickstart — the same decision from a minimal Go program embedding the engine.
Already know what Aperture is? Skip to Installation. Want the big picture first? See the Introduction.
Installation
Aperture ships as a single, statically linked binary with no runtime
dependencies. It is pure Go with CGO_ENABLED=0 end to end — there is no C
toolchain, no external policy engine, and nothing to install alongside it. Build
it from source and you get one file, bin/aperture, that runs anywhere the Go
build targets.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Go | 1.26.1 | The module targets this toolchain; earlier compilers may reject newer language/std usage. |
make | any | The Makefile wraps the build flags; you can also call go build directly. |
| C compiler | none | CGO_ENABLED=0 is enforced — there is nothing to link. |
Build from source
Clone the repository and build the binary with the provided target:
git clone https://github.com/frankbardon/aperture.git
cd aperture
make build
make build compiles the aperture command with CGO_ENABLED=0,
-trimpath, and -ldflags="-s -w", writing the result to bin/aperture.
Confirm the binary works:
bin/aperture --version
aperture version <version>
Build directly with go
If you prefer not to use make, the equivalent invocation is:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/aperture ./cmd/aperture
The cmd/aperture/main.go entrypoint is a thin adapter: it only stamps the
version and hands off to the CLI command tree. All behavior lives in the
library packages at the module root.
Put it on your PATH (optional)
The examples in this book invoke the binary as bin/aperture from the
repository root. To run aperture from anywhere, copy the binary onto your
PATH:
cp bin/aperture /usr/local/bin/aperture
aperture --version
Verify the toolchain
Aperture keeps CGO_ENABLED=0 and stays free of any CGO dependency (no geo/h3,
no SQLite C bindings — the SQLite backend uses the pure-Go modernc.org/sqlite).
If a build fails complaining about a C compiler, an environment variable is
forcing CGO on; clear it and rebuild:
CGO_ENABLED=0 make build
Where to go next
- Concepts — the vocabulary the rest of the book assumes.
- First decision (CLI) — run
aperture checkagainst the built-in example model. - Library quickstart — embed the decision engine in a Go program.
Concepts primer
Aperture answers one question — “is this principal allowed to do this action to this object, and why?” This page defines the vocabulary that question is built from. It is deliberately brief: enough to read any later chapter. Each term links forward to its full treatment in the Concepts section of the book.
Every example on this page is drawn from the committed example model (account
acme), the same fixture the First decision (CLI)
walkthrough uses.
Principal
A principal is the actor a decision is made for — a user, a service, or
any other identity that can attempt an action. In the example model, alice
and bob are principals. Each principal has a stable id, a kind, an identity
string (such as user:alice), and a set of roles and group memberships that
carry its grants. The identity package owns this model.
Action
An action is the verb being attempted — read, write, delete, share.
Actions are declared per object type: a document in the example model permits
read, write, delete, and share. A decision always names exactly one
action.
Object
An object is the resource an action targets, named by a canonical object-identity string. Identities are hierarchical, path-like, and always rooted at an account. In the example model a document is written as:
account:acme/project:atlas/document:42
Each type:id segment narrows the path. This structure is what lets a single
grant cover a whole subtree while a more specific grant overrides it for one
object. The model package defines object types and identities; live object
attributes are resolved by object providers (the provider package).
Identity patterns and specificity
A pattern is an object-identity with wildcards, used by a grant to cover many
objects at once. ** matches any remaining path; a plain segment matches
exactly. In the example model:
account:acme/project:atlas/** # every object under project atlas
account:acme/project:atlas/document:secret # exactly one document
Specificity decides which grant wins when several match. A more specific
pattern (one that matches fewer objects) outranks a broader one. That is how the
example model seals a single document: a broad allow on
account:acme/project:atlas/** lets engineering read everything, while a
narrower deny on account:acme/project:atlas/document:secret overrides it for
that one object. Deny-overrides plus specificity is the core resolution rule.
Grant
A grant binds a subject (a principal, role, or group), a permission (an
object-type + action pair), and an object pattern, with an effect of allow
or deny. Grants are the raw material of every decision. They come in several
flavors: direct grants, time-bounded delegations (delegation), and
impersonation grants that let one principal act as another (impersonation).
Rule
A rule is a conditional predicate that gates a grant on the attributes of the
request — the principal, the object, or the environment. Rules are authored as an
AST that Aperture compiles to an expr-lang/expr
expression and evaluates in-process; there is no external policy service and no
Pulse dependency. The rules package owns compilation and caching.
Scope
A scope narrows what a principal can see or act on within an account —
Aperture’s mechanism for row-level and subtree-level visibility. Scoped reads are
narrowed by the filter package so a listing never returns objects the caller is
not entitled to see. The scope package defines the scoping model.
Account
An account is the top-level tenant boundary. Every object identity is rooted
at an account (account:acme/...), every grant is stamped to an account, and
every decision is scoped to one active account. Accounts are hard isolation
boundaries: a decision or an error message never leaks data from an account the
caller cannot see. Principals join an account through a membership, which the
engine can optionally enforce (a non-member is denied). Accounts are modeled as
entities in the model package.
How they fit together
A Check takes an account, a principal, an action, and an
object. Aperture gathers every grant whose subject includes the principal
and whose pattern matches the object, evaluates any attached rules, applies
deny-overrides by specificity, and returns a verdict plus a reason naming
the deciding grants. The same resolution drives Enumerate (which objects a
principal may act on) and Explain (the full decision trace).
Where to go next
- First decision (CLI) — see these terms resolve into a real verdict.
- Library quickstart — ask the same question from Go.
- The Concepts section of this book expands each term above into its own chapter (identities, the object model, patterns and specificity, rules, scopes, grants, and accounts).
First decision (CLI)
This walkthrough makes real access-control decisions with the aperture binary.
It needs nothing beyond the build from Installation: every
command below runs against Aperture’s embedded example model — a
self-contained fixture for the account acme — so there is no store to set up
and no data to load.
The example model
When you do not pass --seed, aperture check loads the committed example
fixture. It models one tenant, acme, with a project atlas full of documents,
and three grants:
| Grant | Subject | Effect | Object pattern |
|---|---|---|---|
g-eng-read-atlas | group engineering | allow read | account:acme/project:atlas/** |
g-editor-write-atlas | role editor | allow write | account:acme/project:atlas/** |
g-deny-secret-read | group engineering | deny read | account:acme/project:atlas/document:secret |
alice is an editor in engineering; bob is a viewer in engineering. That is
enough to see an allow, a default deny, and a deny-override.
The command
aperture check <principal> <action> <object>
check takes three positional arguments and prints a one-word verdict plus a
reason. The process exit code carries the verdict — 0 for allow, non-zero
for deny — so a check composes in a shell pipeline
(aperture check … && deploy).
Relevant flags:
| Flag | Default | Purpose |
|---|---|---|
--seed | embedded example | Path to a JSON/YAML seed model to decide against. |
--account | acme | The active account the decision is scoped to. |
--store | in-memory | SQLite DSN for a persistent backing store. |
The full flag reference lives in the CLI chapter later in this book; the three above are all this walkthrough needs.
Allow: engineering reads a document
alice is in engineering, and g-eng-read-atlas lets engineering read
everything under atlas:
bin/aperture check alice read account:acme/project:atlas/document:42
allow
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
The command exits 0. The reason names the deciding grant and its
specificity — a broad ** pattern scores low.
Allow: an editor writes
alice also holds the editor role, so g-editor-write-atlas permits a write:
bin/aperture check alice write account:acme/project:atlas/document:42
allow
reason: allowed by grant g-editor-write-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
Default deny: no grant matches
bob is only a viewer — nothing grants him write — so the decision falls through
to a fail-closed default deny:
bin/aperture check bob write account:acme/project:atlas/document:42
deny
reason: default deny: no grant matched action "write" on "account:acme/project:atlas/document:42" for principal "bob" in account "acme"
The command exits non-zero. A decision with no matching grant is always a deny — Aperture fails closed.
Deny by override: specificity wins
g-deny-secret-read seals one document. Even though g-eng-read-atlas would
allow the read, the deny is more specific and overrides it:
bin/aperture check alice read account:acme/project:atlas/document:secret
deny
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
Two grants matched; the deny at specificity 60300 outranks the allow at
39300. This is deny-overrides by specificity — see the
Concepts primer.
Why? — explain
check gives the verdict; explain gives the whole trace. It takes the same
three arguments:
bin/aperture explain alice read account:acme/project:atlas/document:secret
Explain alice/read on account:acme/project:atlas/document:secret in account acme
subjects: principal:alice, role:editor, group:engineering
grants considered (3):
g-eng-read-atlas [allow account:acme/project:atlas/**] allow covers the object via literal scope at specificity 39300
g-editor-write-atlas [allow account:acme/project:atlas/**] action "write" does not match the requested "read"
* g-deny-secret-read [deny account:acme/project:atlas/document:secret] deny covers the object via literal scope at specificity 60300
verdict: DENY (top specificity 60300)
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
The * marks the deciding grant. explain is a first-class operation, not a
debug afterthought — every decision is auditable by construction.
A note on the acting principal
For check, enumerate, and explain, the principal is a positional argument
— the subject of the question. The write commands (put, bestow,
revoke, impersonate, …) are different: they need to know who is acting, and
take that principal from the --principal flag or the APERTURE_PRINCIPAL
environment variable. For example, bestow reads the delegating principal from
APERTURE_PRINCIPAL:
export APERTURE_PRINCIPAL=alice
bin/aperture bestow --help
Setting APERTURE_PRINCIPAL once in your shell saves passing the flag to every
mutation. The read decisions above never consult it — they ask about a principal
you name explicitly.
Where to go next
- Library quickstart — make the same decision from Go.
- Concepts primer — the vocabulary behind the verdicts.
- The CLI section of this book documents every command and flag in full.
Library quickstart
Aperture is library-first: the CLI, the RPC API, the MCP server, and the admin UI are all thin adapters over the same Go packages. This page embeds the decision engine directly and asks it the same question the First decision (CLI) walkthrough asked — this time from Go.
The pieces
A decision needs three collaborators, wired by hand (Aperture uses manual dependency injection — no wire/fx/dig):
| Package | Role |
|---|---|
storage/memory | A backing store for the model. The in-memory implementation is ideal for a demo; storage/sqlite is its persistent twin behind the same interface. |
engine | The decision engine. engine.New(store) binds it to a store. |
service | The facade every surface calls. service.New(engine.New(store)) gives you Check / Enumerate / Explain. |
seed | Loads a model into a store. seed.Example is the embedded acme fixture. |
A minimal program
Save this as main.go inside a module that requires
github.com/frankbardon/aperture:
package main
import (
"context"
"fmt"
"log"
"github.com/frankbardon/aperture/engine"
"github.com/frankbardon/aperture/seed"
"github.com/frankbardon/aperture/service"
"github.com/frankbardon/aperture/storage/memory"
)
func main() {
ctx := context.Background()
// Build an in-memory store and load the embedded example model (account "acme").
store := memory.New()
if err := store.Setup(ctx); err != nil {
log.Fatal(err)
}
if err := seed.Load(ctx, store, seed.Example, seed.FormatYAML); err != nil {
log.Fatal(err)
}
// The service facade is the single entry point every surface uses.
svc := service.New(engine.New(store))
res, err := svc.Check(ctx, service.Query{
Account: seed.ExampleAccount, // "acme"
Principal: "alice",
Action: "read",
Object: "account:acme/project:atlas/document:42",
})
if err != nil {
log.Fatalf("check failed: %v", err)
}
fmt.Printf("allow=%v\n", res.Allow)
fmt.Printf("reason: %s\n", res.Reason)
fmt.Printf("deciding grants: %v\n", res.DecidingGrantIDs)
}
Run it:
go run .
allow=true
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
deciding grants: [g-eng-read-atlas]
That is the identical decision, reason, and deciding grant the CLI printed — because it is the same code path. There is one engine; every surface is a translator over it.
The request and result types
service.Check takes a service.Query and returns a service.Result:
type Query struct {
Account string // active account the decision is scoped to
Principal string // id of the principal asking
Action string // the verb being attempted
Object string // canonical object-identity string
}
type Result struct {
Allow bool // the verdict
Reason string // human-readable explanation
DecidingGrantIDs []string // grant ids that produced the verdict
}
Result never surfaces raw errors as denies inconsistently: an operational
failure fails closed (Allow: false) with the cause in Reason, while a
malformed request returns a non-nil error carrying an APERTURE_* code. Recover
that code with errors.CodeOf from the errors package.
Errors are coded
Every failure Aperture returns across a package boundary is an APERTURE_* coded
error, not a bare string. When Check returns a non-nil error — for example, a
malformed object identity — inspect the code rather than the message:
import aerr "github.com/frankbardon/aperture/errors"
if err != nil {
switch aerr.CodeOf(err) {
case aerr.APERTURE_INVALID_INPUT:
// the query was malformed — fix the caller
default:
// something operational went wrong
}
}
Beyond Check
The facade exposes the whole read API — Enumerate (which objects a principal
may act on), Explain (the full decision trace), and batch forms of each —
plus, when constructed with the right options, the mutation path (entity CRUD,
grants, delegation, impersonation). A read-only service.New(eng) returns
APERTURE_UNIMPLEMENTED from any mutation, so a decision-only surface stays
minimal.
Where to go next
- Concepts primer — the vocabulary behind
QueryandResult. - The CLI & Library section of this book covers the full Go embedding API.
- The Reference section lists every
APERTURE_*error code and its fixups.
CLI overview
Audience: operators and integrators driving Aperture from a shell.
aperture is the command-line face of the same decision engine the
library, the RPC/HTTP server, and the MCP surface all
call. Every subcommand is a thin adapter: it parses flags, hand-wires the
storage → engine → service graph, makes exactly one call into the library, and
maps the result to output plus an exit code. There is no CLI-only behaviour — a
check from the shell resolves through the identical code path a Check RPC
does.
This part of the book is the narrative CLI guide, grouped by what each command
family is for. It links into the generated
Command-Line Reference, which is produced from the live
urfave/cli command tree and holds the authoritative, per-command flag tables.
When you want the exact flags for a command, follow the link on that command’s
name — this guide never re-tabulates them, so the two never drift.
Command families
| Family | Commands | What it does |
|---|---|---|
| Decisions | check, enumerate, explain, identifiers | Ask and audit access-control questions (read-only). |
| Mutations | put, get, list, delete, bestow, revoke, impersonate | Read and change the model — entities, grants, delegation, impersonation. |
| Provisioning | template, bulk | Apply parameterized templates and transactional bulk grant/revoke. |
| Portability | export, import | Serialize the whole model to a state file and apply it back. |
serve | serve | Run the HTTP + Twirp server and admin UI. |
mcp | mcp | Serve the read-only MCP surface over stdio. |
The embedded example model
Every command runs against a model. When you pass neither --seed nor --store,
Aperture loads a committed example fixture — a self-contained model for the
account acme — so the read commands below work with no setup. The fixture,
its grants, and its principals are walked through in
First decision (CLI). The examples in
this guide use that fixture unless they say otherwise, and never reference data
from any other account.
Two ways to select a model
The commonly shared options — --seed, --store, --account, and
--principal — are defined per command, not as root persistent flags, and
they mean the same thing everywhere they appear. They are documented once in
Global options; each family page below links back to that
page rather than re-explaining them.
Related
- Global options —
--seed/--store/--account/--principal. - Command-Line Reference — the generated per-command flag tables.
- First decision (CLI) — the example model, end to end.
- Library overview — the same engine, embedded in Go.
Global options
Audience: operators and integrators driving Aperture from a shell.
aperture declares no persistent global flags. The four options that recur
across the command tree — --seed, --store, --account, and --principal —
are defined per command, so they appear in each command’s flag table in the
Command-Line Reference. They carry the same meaning
wherever they appear; this page is the single explanation the family pages link
back to.
Selecting a model: --seed and --store
Every command resolves its model from these two options, in this order:
| Option | Default | Meaning |
|---|---|---|
--seed | embedded example | Path to a JSON/YAML seed model to load. When omitted, the committed example fixture is used. |
--store | in-memory | SQLite DSN for a persistent backing store. When omitted, an in-memory store is built and seeded, then discarded when the command exits. |
Use --seed to point at your own model file for a one-shot decision, and
--store when you want changes to persist to disk across invocations. A store
built from --store is seeded from --seed (or the embedded example) the first
time it is populated.
# Decide against a model file, no persistence:
bin/aperture check alice read account:acme/project:atlas/document:42 \
--seed ./my-model.yaml
# Persist mutations to a SQLite file so a later command sees them:
bin/aperture put grant --principal root --account acme \
--store ./aperture.db --file ./grant.json
Scoping a decision: --account
--account names the active account a decision or mutation is scoped to. Its
behaviour differs by command family:
- On the decision commands (
check,enumerate,explain),--accountdefaults toacme(the example account) and bounds which grants the decision considers. - On mutation, provisioning, and portability commands,
--accounthas no default and is the active account used to resolve the acting principal’s admin tier (account-admin vs system-admin). Several of those commands require it.
Aperture never lets one account’s data surface in another account’s decision, and error messages never leak cross-account detail.
The acting principal: --principal
This is the option most worth getting right.
On the read decision commands, the principal is a positional argument — the subject of the question you are asking:
bin/aperture check alice read account:acme/project:atlas/document:42
# ^^^^^ the subject principal, positional — NOT --principal
On the write / mutation commands (put, delete, bestow, revoke,
impersonate, template, bulk, export, import), --principal is a
flag that names the authenticated caller performing the mutation — who is
acting, not who is being asked about. It is sourced from the
APERTURE_PRINCIPAL environment variable, so you can set it once per shell:
export APERTURE_PRINCIPAL=root
bin/aperture put role --account acme --file ./role.json
bin/aperture delete grant g-old --account acme
A mutation with no --principal (and no APERTURE_PRINCIPAL) fails with
APERTURE_UNAUTHENTICATED. A few commands name the acting principal with a
purpose-specific flag instead — bestow/revoke use --delegator, and
impersonate uses --operator — but each of those still reads from
APERTURE_PRINCIPAL as its default source.
Related
- Command-Line Reference — every command’s full flag table.
- Decisions — where the principal is positional.
- Mutations — where
--principalis the acting caller. - First decision (CLI) — the note on subject vs actor, worked through.
Decisions
Audience: operators and integrators asking and auditing access-control questions from a shell.
The decision commands are the read-only core of the CLI. They never change the
model — they ask a question of it. All three of check, enumerate, and
explain take the subject principal as a positional argument (the principal
the question is about), not a --principal flag; see
Global options for why the
write commands differ. identifiers inspects an object type’s provider.
check — decide one question
aperture check [options] <principal> <action> <object>
check prints a one-word verdict (allow / deny) and a reason, and carries
the verdict in its exit code — 0 for allow, non-zero for deny — so it
composes in a pipeline (aperture check … && deploy).
bin/aperture check alice read account:acme/project:atlas/document:42
allow
reason: allowed by grant g-eng-read-atlas (allow account:acme/project:atlas/**) at specificity 39300; 1 matching grant(s) considered
A question with no matching grant is always a deny — Aperture fails closed:
bin/aperture check bob write account:acme/project:atlas/document:42
deny
reason: default deny: no grant matched action "write" on "account:acme/project:atlas/document:42" for principal "bob" in account "acme"
Full flags: check.
explain — why a decision resolved
aperture explain [options] <principal> <action> <object>
explain takes the same three arguments as check and prints the whole
decision trace: the subject set, every grant considered, why each did or did not
apply, and the deciding grant (marked *). It is a first-class operation, not a
debug afterthought.
bin/aperture explain alice read account:acme/project:atlas/document:secret
Explain alice/read on account:acme/project:atlas/document:secret in account acme
subjects: principal:alice, role:editor, group:engineering
grants considered (3):
g-eng-read-atlas [allow account:acme/project:atlas/**] allow covers the object via literal scope at specificity 39300
g-editor-write-atlas [allow account:acme/project:atlas/**] action "write" does not match the requested "read"
* g-deny-secret-read [deny account:acme/project:atlas/document:secret] deny covers the object via literal scope at specificity 60300
verdict: DENY (top specificity 60300)
reason: denied by grant g-deny-secret-read (deny account:acme/project:atlas/document:secret) at specificity 60300; 2 matching grant(s) considered
Full flags: explain.
enumerate — list objects a principal may act on
aperture enumerate [options] <principal> <action> <pattern>
enumerate turns the question around: instead of one object, it lists the
object ids under a <pattern> that the principal may take <action> on, one id
per line. --limit caps the result count. Enumeration expands objects from the
object providers declared in the model’s providers: section, so a model with
no providers (like the embedded example) yields an empty list — use enumerate
against a seed that declares a provider for the type.
bin/aperture enumerate alice read 'account:acme/project:atlas/document:*' \
--seed ./model-with-providers.yaml --limit 100
Full flags: enumerate.
identifiers — a type’s valid instance ids
aperture identifiers [options] <object_type>
identifiers lists every valid instance id of an object type, read from the
provider that the model’s providers: section binds to that type (a CSV file
today, a data source later). --exclude drops ids from the result — this is how
an exclusive “all except these” allowance expands into a positive allow-list.
Because it needs a provider, identifiers errors with
APERTURE_PROVIDER_UNREGISTERED against a model that declares none, so run it
against a seed that binds the type:
bin/aperture identifiers document --seed ./model-with-providers.yaml
bin/aperture identifiers document --seed ./model-with-providers.yaml --exclude secret
Full flags: identifiers.
Related
- Global options —
--seed/--store/--account, and why the principal is positional here. - First decision (CLI) — the same commands walked through against the example model.
- Mutations — change the grants these decisions read.
- Command-Line Reference — the generated flag tables.
Mutations
Audience: administrators changing the Aperture model from a shell.
The mutation commands read and change the model: generic entity CRUD (put,
get, list, delete), delegation (bestow, revoke), and impersonation
(impersonate). Each builds the fully-wired facade — the same
storage → engine → gate → delegation → impersonation → service graph the
serve command mounts — so a CLI mutation is gated exactly as the HTTP/Twirp
surface gates it. There is no CLI-only write path.
Every write command needs an acting principal: the authenticated caller
performing the change, taken from --principal (or APERTURE_PRINCIPAL) — not a
positional argument. The reads (get, list) need no actor. See
Global options for the
subject-vs-actor distinction, and set it once per shell:
export APERTURE_PRINCIPAL=root
Entity bodies are the canonical JSON encoding of the corresponding model.*
struct — the same shape the Twirp entity_json field carries. Supply one with
--json, --file, or on stdin (in that order).
Entity CRUD: put, get, list, delete
put <kind> creates or updates one entity; the admin tier it requires depends
on the kind. It prints put <kind> ok.
bin/aperture put grant --account acme --json '{
"id": "g-analyst-read",
"accountId": "acme",
"subject": {"kind": "role", "id": "analyst"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/**",
"effect": "allow"
}'
get <kind> <id> reads one entity as pretty JSON (no actor, no tier):
bin/aperture get grant g-eng-read-atlas
{
"ID": "g-eng-read-atlas",
"AccountID": "acme",
"Subject": { "Kind": "group", "ID": "engineering" },
"PermissionID": "perm-doc-read",
"Object": "account:acme/project:atlas/**",
"Effect": "allow"
}
list <kind> lists entities of a kind as JSON. Listing grants requires
--account (grants are per-account); the other kinds do not:
bin/aperture list principals
bin/aperture list grants --account acme
delete <kind> <id> removes one entity, gated by the kind’s tier; it prints
delete <kind> <id> ok. Memberships are keyed by (principal, account) rather
than a single id, so delete membership takes --principal-id and
--account-id instead:
bin/aperture delete grant g-analyst-read --account acme
bin/aperture delete membership --account acme \
--principal-id bob --account-id acme
Full flags: put,
get,
list,
delete.
Delegation: bestow and revoke
bestow lets a principal delegate a grant it already holds to another
principal. Unlike put grant, it is not gated by an admin tier — it enforces the
delegation subset rule: you can only bestow authority you hold, over a
delegatable permission. The delegating principal is named by --delegator (env
APERTURE_PRINCIPAL); the grant body is a normal grant JSON. It prints
bestow <grant-id> ok.
bin/aperture bestow --delegator alice --json '{
"id": "g-bob-read-42",
"accountId": "acme",
"subject": {"kind": "principal", "id": "bob"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/document:42",
"effect": "allow"
}'
revoke is the inverse: it removes a grant the delegator previously bestowed,
by id. It prints revoke <grant-id> ok.
bin/aperture revoke --delegator alice --grant g-bob-read-42
Impersonation: impersonate
impersonate starts a time-boxed session in which an --operator acts as a
--target within --account, and prints the session as JSON. --mode is
augment (add the target’s authority to the operator’s — the default) or
become (resolve purely as the target). It is guarded: an operator with no
right covering the target is denied with APERTURE_IMPERSONATION_DENIED.
bin/aperture impersonate --operator root --target alice --account acme --mode augment
Full flags: impersonate.
Related
- Global options —
--principal/--account/--seed/--store. - Decisions — check the effect of a grant you just changed.
- Provisioning — apply many grants at once via templates or
bulk. - Portability — move a whole model between stores.
- Command-Line Reference — the generated flag tables.
Provisioning
Audience: administrators granting access at scale from a shell.
The provisioning commands turn repeated grant-making into one transactional
call: template manages and applies parameterized grant bundles, and bulk
applies or removes many grants atomically. Both build the same fully-wired,
tier-gated facade the mutation commands use, and both need an
acting principal via --principal (or APERTURE_PRINCIPAL) plus, in most cases,
--account. Set the principal once:
export APERTURE_PRINCIPAL=root
template — parameterized grant bundles
aperture template <put|get|list|delete|apply>
A template is a named, versioned bundle of grants with named parameters. The subcommands split into CRUD and apply:
| Subcommand | Tier | What it does |
|---|---|---|
template put | system-admin | Create or update a template (from --json / --file / stdin). Prints put template <name> v<version> ok. |
template get <name> | none | Read a template as JSON (latest unless --version). |
template list | none | List every template version as JSON. |
template delete <name> | system-admin | Delete one --version, or all versions when --version is 0. |
template apply | account-admin | Instantiate a template’s grants transactionally into --account. |
template apply binds --param name=value (repeatable) into the template and
writes the resulting grants in one transaction; --id-prefix prefixes the
generated grant ids, and --version 0 (the default) applies the latest. It
prints the applied grants as JSON.
# Define a template (system-admin tier):
bin/aperture template put --account acme --file ./project-onboarding.json
# Apply it into an account, binding parameters (account-admin tier):
bin/aperture template apply --account acme \
--name project-onboarding \
--param project=atlas \
--param team=engineering \
--id-prefix onboard-
Full flags: template.
bulk — many grants in one transaction
aperture bulk <grant|revoke>
bulk grant applies a JSON array of grant bodies atomically — either all
land or none do — from --json, --file, or stdin. It prints bulk grant <n> ok.
bin/aperture bulk grant --account acme --json '[
{"id":"g-a","accountId":"acme","subject":{"kind":"role","id":"analyst"},"permissionId":"perm-doc-read","object":"account:acme/project:atlas/**","effect":"allow"},
{"id":"g-b","accountId":"acme","subject":{"kind":"role","id":"analyst"},"permissionId":"perm-doc-write","object":"account:acme/project:atlas/document:42","effect":"allow"}
]'
bulk revoke deletes many grants atomically by id. Ids come from repeated
--grant flags, positional arguments, or both; it prints bulk revoke <n> ok.
bin/aperture bulk revoke --account acme --grant g-a --grant g-b
# or positionally:
bin/aperture bulk revoke --account acme g-a g-b
Both bulk subcommands are account-admin tier.
Full flags: bulk.
Related
- Global options —
--principal/--account/--seed/--store. - Mutations — single-entity
put/deleteand delegation. - Portability — export/import a whole model at once.
- Command-Line Reference — the generated flag tables.
Portability
Audience: administrators moving or backing up a whole Aperture model.
export and import are the declarative-state commands: export serializes the
entire model to a single JSON/YAML state file, and import applies such a file
back as an idempotent, transactional upsert. Both are system-admin tier and
drive exactly the path the Twirp Export / Import RPCs drive, so they need an
acting principal via --principal (or APERTURE_PRINCIPAL) and --account for
authority resolution:
export APERTURE_PRINCIPAL=root
export — serialize the whole model
aperture export [options]
export writes the full model to stdout by default, or to --out <path>. The
format is JSON unless you pass --format yaml (or --out a .yaml/.yml
path). Writing to a file prints a one-line summary; writing to stdout emits the
raw document.
# To stdout as JSON:
bin/aperture export --account acme --principal root > model.json
# To a YAML file (format inferred from the extension):
bin/aperture export --account acme --principal root --out model.yaml
exported <model summary> -> model.yaml
Full flags: export.
import — apply a state file
aperture import [options]
import reads a state file from --file <path> or, when no file is given, from
stdin (treated as JSON). It applies the document as an idempotent upsert in
one transaction — re-importing the same file is a no-op — and prints a one-line
summary. The file format is inferred from the --file extension.
# From a file:
bin/aperture import --account acme --principal root --file model.yaml
# From stdin (JSON):
bin/aperture export --account acme --principal root \
| bin/aperture import --account acme --principal root
imported <model summary>
Round-tripping through export | import — or between two --store DSNs — is the
supported way to snapshot, back up, or migrate a model. Because both ends run as
a system-admin actor scoped to --account, no cross-account data crosses the
boundary implicitly.
Full flags: import.
Related
- Global options —
--principal/--account/--storefor the source/target store. - Provisioning — apply incremental grants rather than a whole model.
- Mutations — single-entity edits.
- Command-Line Reference — the generated flag tables.
serve
Audience: operators running Aperture as a long-lived service.
aperture serve [options]
serve hand-wires the full dependency graph (storage → engine → service → HTTP handler) and boots a net/http server exposing the HTTP + Twirp API and
the admin UI. It shuts down gracefully on SIGINT / SIGTERM, draining
in-flight requests within a 10-second window. This is the same fully-wired facade
the mutation CLI commands build — the server just puts it behind a listener and
an authenticator.
bin/aperture serve --addr :8080
aperture serving on :8080
Press Ctrl-C to trigger a graceful shutdown (shutting down...).
What the flags control
--addr— the TCP address to listen on (default:8080).--seed/--store— the model to serve, exactly as elsewhere (see Global options). With no--store, the server runs against an in-memory model seeded from--seedor the embedded example.--auth— the authenticator adapter that maps each request to a principal:dev(the default — the bearer token is the principal id, no external IdP),oidc, orparsec. It overrides theAPERTURE_AUTH_MODEenv var. Because the default isdev,serveruns with no external identity provider out of the box;oidcandparsecare opt-in.--enforce-membership— defence-in-depth: deny any decision whose principal is not a member of the active account before grants are consulted. This lets a single shared role (manager, analyst, …) be reused across accounts without one account’s grants leaking to another’s members. Also settable viaAPERTURE_ENFORCE_MEMBERSHIP.
Under serve, the facade is wired with everything the other surfaces expect: the
admin gate, delegation and impersonation mutators, the append-only audit trail,
the rules engine over a storage-backed rule source, and the object providers
declared in the seed’s providers: section. A rule saved through the admin UI
takes effect on the next decision with no separate rule store.
Full flags: serve.
Related
- Global options —
--seed/--store. - mcp — the read-only stdio surface, for MCP clients rather than HTTP.
- Command-Line Reference — the generated flag table.
mcp
Audience: integrators wiring Aperture into an MCP client (an AI assistant or agent runtime).
aperture mcp [options]
mcp serves Aperture’s read-only decision surface over stdio — the transport
an MCP client uses when it spawns Aperture as a subprocess. It exposes the
decision API (check / enumerate / explain, single and bulk), a read-only
what-if simulator, and model inspection as MCP tools. No tool mutates. The
command wires the facade with storage for inspection and what-if reads, but
deliberately not the gate, delegation, or impersonation mutators — the surface
can never write.
Because it speaks the MCP protocol over stdio, you normally don’t run mcp
interactively; an MCP client launches it. A minimal client configuration points
at the binary and the model to serve:
{
"mcpServers": {
"aperture": {
"command": "bin/aperture",
"args": ["mcp", "--store", "./aperture.db"]
}
}
}
On start it prints one line to stderr (stdout is reserved for the protocol):
aperture mcp: serving read-only MCP surface over stdio
What the flags control
--seed/--store— the model the surface reads, exactly as elsewhere (see Global options). With neither, it serves the embedded example model over an in-memory store.
There are no other flags: the surface is read-only by construction, so it needs no acting principal, account, or auth adapter.
Full flags: mcp.
Related
- Global options —
--seed/--store. - serve — the read/write HTTP + Twirp surface.
- Decisions — the same check/enumerate/explain questions from a shell.
- Command-Line Reference — the generated flag table.
Library overview
Aperture is library-first: the public Go packages at the module root are the product, and every other surface — the CLI, the RPC/HTTP API, the MCP server, and the admin UI — is a thin translator over them. This part of the book is the reference for embedding Aperture directly in a Go host program.
If you have not run the Library quickstart
yet, start there: it wires a store, an engine, and the facade end to end and asks
one Check. This section then documents the full decision API those pieces
expose.
Two layers, one decision
A host program drives Aperture through two collaborating packages:
| Package | Type | Role |
|---|---|---|
engine | *engine.Engine | The Policy Decision Point (PDP). It resolves a raw authorization question against storage with deny-overrides plus a specificity tiebreak. It is stateless beyond its storage handle and safe for concurrent use. |
service | *service.Service | The decision facade every surface calls instead of touching the engine directly. It adds one shared fail-closed rendering policy, decision auditing, the what-if Simulate path, and — when wired with options — the mutation surface. |
The engine answers the pure question; the facade is where the surface-facing policy lives (how an operational error becomes a rendered deny, when a decision is audited, how a what-if overlay is layered). A host that wants the raw PDP can call the engine; a host that is building its own surface should call the facade, so it inherits the same fail-closed contract every built-in surface has.
import (
"github.com/frankbardon/aperture/engine"
"github.com/frankbardon/aperture/service"
"github.com/frankbardon/aperture/storage/memory"
)
store := memory.New()
_ = store.Setup(ctx)
eng := engine.New(store) // the PDP
svc := service.New(eng) // the facade every surface calls
engine.New(store, opts...) and service.New(eng, opts...) both take functional
options; see Constructing the engine
and The service facade for the options each exposes.
The decision API
Both layers expose the same three operations, in single and bulk-batched forms, with an impersonation-aware variant of each on the engine:
| Operation | Question it answers | Single | Bulk | Impersonated |
|---|---|---|---|---|
| Check | May this principal take this action on this one object? | Check | CheckBatch | CheckAs |
| Enumerate | Which objects under a pattern may this principal act on? | Enumerate | EnumerateBatch | EnumerateAs |
| Explain | Why was this decision reached — which grants and at what specificity? | Explain | ExplainBatch | ExplainAs |
The pages that follow cover each cluster:
- Decision API — the single
Check/Enumerate/Explainoperations: real signatures, input and result shapes, and when to reach for each. - Batch operations —
CheckBatch/EnumerateBatch/ExplainBatchand the genericBatchResult[T]type that keeps one bad query from failing a whole batch. - Impersonation —
CheckAs/EnumerateAs/ExplainAsand theImpersonationContextdecorator that steers which subject set a decision resolves over. - The service facade — how surfaces call the facade, its
fail-closed rendering, and the read-only
Simulate/SimulateExplainwhat-if path with a worked example.
Errors are always coded
Every failure Aperture returns across a package boundary is an APERTURE_*
coded error, never a bare string. Recover the code with errors.CodeOf rather
than matching on the message:
import aerr "github.com/frankbardon/aperture/errors"
if err != nil {
switch aerr.CodeOf(err) {
case aerr.APERTURE_INVALID_INPUT:
// the query was malformed — a caller bug
case aerr.APERTURE_NOT_FOUND:
// an unknown principal, permission, or entity
default:
// an operational failure (storage, an unresolvable strategy, ...)
}
}
The engine and the facade differ in how they treat these errors — the engine returns them, while the facade folds operational failures into a fail-closed deny. The distinction is spelled out per operation on the pages below and in The service facade. The full catalog lives in the Error Codes reference.
The vocabulary these operations are built from — principal, action, object, pattern, specificity, grant, account — is defined in the Concepts primer and expanded in the Concepts section of this book.
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:
| Option | Effect |
|---|---|
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. |
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 rawengine.Checkhere 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
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.
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
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.
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.
When to use each
| Question | Operation |
|---|---|
| “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.
Related
- Batch operations — resolve many of these in one call.
- Impersonation — the
*Asvariants that resolve over an elevated subject set. - The service facade — the fail-closed wrapper surfaces call.
- Concepts primer — deny-overrides, specificity, and the subject set these operations resolve over.
Batch operations
Each of the three single decision operations has a bulk form that resolves many
requests in one call: CheckBatch, EnumerateBatch, and ExplainBatch. They
exist so a surface — the RPC bulk RPCs, the MCP tools, the what-if simulator — can
answer a list of questions in one round trip while keeping each answer isolated:
one bad query never fails the whole batch.
The batch forms live on both layers with the same shape. This page shows the
engine methods; the facade exposes the same three over its
surface-neutral Query / EnumerateQuery types.
BatchResult[T]
Every batch op returns a slice of BatchResult[T], aligned by index with the
input: result[i] is the outcome of reqs[i].
type BatchResult[T any] struct {
Result T // the item's answer when Err is nil; the zero value otherwise
Err error // the item's coded error when it failed, or nil on success
}
Exactly one of the two fields is meaningful per item. When Err is non-nil the
Result is the zero value and the caller reads Err; otherwise Result holds
the answer. The generic parameter is the per-op result type: Decision for
CheckBatch, []string for EnumerateBatch, and Trace for ExplainBatch.
Iterate a batch by checking each item’s Err before reading its Result:
for i, item := range results {
if item.Err != nil {
log.Printf("query %d failed: %v", i, item.Err)
continue
}
use(item.Result)
}
CheckBatch
func (e *Engine) CheckBatch(ctx context.Context, reqs []Request) []BatchResult[Decision]
Resolves many Check requests, returning []BatchResult[Decision] aligned with
reqs. A request that errors yields an item with Err set and a zero Decision;
its siblings are unaffected. A nil reqs yields a nil result.
results := eng.CheckBatch(ctx, []engine.Request{
{Account: "acme", Principal: "alice", Action: "read", Object: "account:acme/project:atlas/document:42"},
{Account: "acme", Principal: "alice", Action: "write", Object: "account:acme/project:atlas/document:42"},
})
for i, item := range results {
if item.Err != nil {
continue // a malformed request — item.Result is the zero Decision
}
fmt.Printf("query %d: allow=%v\n", i, item.Result.Allow)
}
EnumerateBatch
func (e *Engine) EnumerateBatch(ctx context.Context, reqs []EnumerateRequest) []BatchResult[[]string]
Resolves many Enumerate requests, aligned with reqs — result[i] is the id
list for reqs[i]. A request that errors yields an item with Err set and a
nil list; the rest are unaffected.
results := eng.EnumerateBatch(ctx, []engine.EnumerateRequest{
{Account: "acme", Principal: "alice", Action: "read", Pattern: "account:acme/project:atlas/**"},
{Account: "acme", Principal: "alice", Action: "read", Pattern: "account:acme/project:nimbus/**"},
})
ExplainBatch
func (e *Engine) ExplainBatch(ctx context.Context, reqs []Request) []BatchResult[Trace]
Resolves many Explain requests, aligned with reqs. A request that errors
yields an item with Err set and a zero Trace; the rest are unaffected.
Facade batch forms
The service facade exposes the same three over its
surface-neutral query types, so a surface never touches the engine’s Request
type:
func (s *Service) CheckBatch(ctx context.Context, qs []Query) []engine.BatchResult[Result]
func (s *Service) EnumerateBatch(ctx context.Context, qs []EnumerateQuery) []engine.BatchResult[[]string]
func (s *Service) ExplainBatch(ctx context.Context, qs []Query) []engine.BatchResult[engine.Trace]
Note where the fail-closed contract lands. Service.CheckBatch renders each item
exactly as Service.Check: an operational failure folds into a deny Result
(with Err nil), while an input-validation failure sets the item’s Err. So a
CheckBatch item’s Err is only ever a caller-bug error, never a storage fault
— the storage fault already became a fail-closed deny. EnumerateBatch and
ExplainBatch carry engine errors verbatim in each item’s Err. See
The service facade for the full rendering rules.
Like the engine forms, every facade batch method returns nil for a nil input
slice.
Related
- Decision API — the single operations these batch.
- The service facade — the fail-closed rendering the facade batch forms inherit.
Impersonation
The engine exposes an impersonation-aware sibling of each decision operation:
CheckAs, EnumerateAs, and ExplainAs. They resolve a decision over an
effective subject set — the target’s authority, borrowed by an operator — while
recording the real operator for audit. They never mutate stored grants; an
ImpersonationContext only steers which subject set the engine resolves over.
ImpersonationContext
The decorator that carries a session into the engine:
type ImpersonationContext struct {
RealActor string // the operator's principal id (the audit identity)
EffectiveSubject string // the target's principal id (whose authority is used)
Mode Mode // augment or become (or none, which is inert)
ExpiresAt time.Time // the session's hard expiry instant
}
- RealActor is the operator — the principal that truly issued the request and under whose identity audit attributes the action.
- EffectiveSubject is the target — the principal whose authority the decision borrows.
- Mode selects augment vs become (below).
- ExpiresAt is a hard time-box the engine enforces with its injected clock. A presented-but-expired context fails closed to no elevation (the operator’s own authority), never to the target’s.
Mode
const (
ModeNone Mode = "" // no impersonation — the inert zero value
ModeAugment Mode = "augment" // ADD the target's permissions to the operator's own
ModeBecome Mode = "become" // FULLY assume the target's identity for the decision
)
ModeAugmentresolves over the union of the operator’s and the target’s subject sets, but the operator keeps acting under its own identity. Use it to “see what they can see” while retaining your own authority.ModeBecomeresolves over the target’s subject set alone, as if the target had asked — the operator’s own grants do not apply. Become is the strictly stronger mode and is gated by a stronger right (see theimpersonationpackage). The audit trail still records the real operator.ModeNoneis the inert default: a zeroImpersonationContextconfers no elevation, and the*Asoperation delegates straight to its plain sibling.
Mode.Valid() reports whether a mode is recognised (none counts as valid).
The operations
func (e *Engine) CheckAs(ctx context.Context, req Request, ic ImpersonationContext) (Decision, error)
func (e *Engine) EnumerateAs(ctx context.Context, req EnumerateRequest, ic ImpersonationContext) ([]string, error)
func (e *Engine) ExplainAs(ctx context.Context, req Request, ic ImpersonationContext) (Trace, error)
Each takes the same request its plain sibling takes, plus the
ImpersonationContext. Behaviour is identical to the plain operation except for
the subject set the decision resolves over.
Rules for an active session
An ImpersonationContext is active when its mode is augment or become and its
ExpiresAt is still in the future (per the engine’s clock). For an active
session:
- The request’s principal must be the operator:
req.Principal == ic.RealActor. A mismatch is a caller bug and surfaces asAPERTURE_INVALID_INPUT, not a deny. - Augment resolves over operator ∪ target subjects; become resolves over the target alone.
- The operator and the target must both be members of the active account,
else the decision is a fail-closed deny — cross-account impersonation is
refused. (
CheckAs/ExplainAsreturn a deny with no deciding grant;EnumerateAsreturns the empty set.) - The returned
Decision/Tracecarriesicon itsImpersonationfield for audit. In aTrace,Subjectsis the effective subject set whileRequest.Principalremains the real operator — so a trace shows both who asked and whose authority answered.
Inert sessions fail closed
When ic is inert — mode none, or an expired session — the *As operation
delegates straight to its plain sibling. An expired become session therefore
resolves as the operator’s own authority with no elevation, never as the
target’s. Elevation never outlives its time-box.
ic := engine.ImpersonationContext{
RealActor: "alice", // the operator issuing the request
EffectiveSubject: "bob", // the target whose access is borrowed
Mode: engine.ModeBecome,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
dec, err := eng.CheckAs(ctx, engine.Request{
Account: "acme",
Principal: "alice", // MUST equal ic.RealActor
Action: "read",
Object: "account:acme/project:atlas/document:42",
}, ic)
if err != nil {
return err
}
// dec resolves over bob's authority; dec.Impersonation records alice as the real actor.
fmt.Println(dec.Allow, dec.Impersonation.RealActor)
Carrying impersonation on the context
The engine also exposes context helpers so a middleware layer — most importantly the audit layer — can read the real actor and effective subject of any decision made while a session is set:
func WithImpersonation(ctx context.Context, ic ImpersonationContext) context.Context
func ImpersonationFromContext(ctx context.Context) (ImpersonationContext, bool)
The *As entry points set this on the context they evaluate under; a surface may
also set it before calling so audit middleware wrapping the engine sees it.
Related
- Decision API — the plain operations these mirror.
- The service facade — surfaces reach impersonation through
the facade’s impersonation service (wired with
WithImpersonation). - The
impersonationpackage — session issuance and the rights that gate augment vs become.
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:
| Option | Enables |
|---|---|
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 outcome | Facade renders it as |
|---|---|
| A clean decision | Passes 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.
Related what-if reads
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.
Related
- Decision API — the raw engine operations the facade wraps.
- Batch operations — the facade’s per-item batch rendering.
- Impersonation — the engine’s
*Asoperations. - Library quickstart — the facade wired end to end.
RPC / HTTP overview
Audience: engineers integrating a non-CLI consumer (a service, a script, or
the admin UI) against a running aperture serve.
Aperture exposes its full access-control API over HTTP as a
Twirp service. Twirp is a plain
request/response RPC framework: every method is an HTTP POST to a fixed URL,
with a JSON (or protobuf) body and a JSON (or protobuf) reply. There is no
streaming, no custom verbs, and no URL-encoded parameters — just one POST per
call. This makes the surface trivially reachable from curl, any HTTP client,
or a generated Twirp stub.
The service facade is the one code path
Everything on the wire is a thin translation onto the service.Service facade —
the same facade the CLI drives and the same one the admin UI calls. HTTP / Twirp
/ CLI therefore share one decision engine, one mutation path, and one
auth + admin-tier policy. A handler decodes the request, calls exactly one facade
method, and encodes the result; there is no business logic in the transport
layer (internal/server/).
Concretely, internal/server/twirp.go implements the generated
rpc.ApertureService interface, and each method body is a few lines: decode,
call h.svc.<Method>(...), encode. If a behaviour is not described here, it is
governed by the facade and documented under
The service facade.
Transport and endpoints
The Twirp service is mounted on a net/http ServeMux in
internal/server/server.go, under a fixed base path:
/twirp/aperture.ApertureService/<Method>
- Method is the RPC name exactly as it appears in the proto (
Check,PutGrant,Enumerate, …). - Content-Type selects the codec:
application/jsonfor JSON bodies (the form shown throughout these docs) orapplication/protobuffor the binary form. Both are the standard Twirp codecs; the JSON encoding is identical to the library’s own JSON. - HTTP status is always
200for a successful call — including a decision of deny, which is a successful answer, not an error. Failures map an Aperture coded error onto a Twirp error code and its HTTP status (see Errors below).
Two smaller routes share the same mux for convenience:
| Route | Purpose |
|---|---|
POST /check | The minimal plain-HTTP decision path (a single Check), preserved so the simplest decision call needs no Twirp client. It calls the same facade with identical fail-closed semantics. |
GET /healthz | Liveness probe; returns 200 ok. |
GET / (and everything more specific losing to the API routes) | The embedded admin UI shell (documented in the Admin UI chapter). |
The server is started by aperture serve, which listens on
--addr (default :8080) and wraps the whole mux in the authentication
middleware.
service.proto is the source of truth
The canonical, machine-readable contract is
internal/wire/rpc/service.proto.
It declares roughly 60 RPCs and every request/response message. The committed
service.pb.go / service.twirp.go are generated from it (make proto).
The reference in the next page is hand-authored: generating a page from the proto is out of scope, so the catalog summarises each RPC’s purpose and points back to the proto for exact field lists. When in doubt about a field name or a message shape, read the proto — it is authoritative. If an RPC is added to the proto and this chapter is not updated, the proto wins; treat any discrepancy as a docs bug, not a contract change.
Auth model
Authentication is applied as net/http middleware (server.Authenticate,
internal/server/middleware.go), wired in front of the whole mux by the serve
command. It reads a bearer credential from the Authorization header and, on
success, attaches the resolved Aperture principal to the request context:
- No credential → the request proceeds anonymously (no principal in context).
- A valid credential → the resolved principal is attached and the request proceeds as that identity.
- A bad credential → the request is refused
401with a coded error (APERTURE_INVALID_TOKEN/APERTURE_UNAUTHENTICATED). A bad token is a hard failure, never silently downgraded to anonymous.
The authenticator adapter is chosen by --auth / APERTURE_AUTH_MODE; the
default dev adapter treats the bearer token as the principal id, so Aperture
runs with no external IdP out of the box (oidc and parsec are opt-in). See
serve.
On top of that, each RPC enforces its own requirement, owned by the Twirp handler and the facade gate:
| Class of RPC | Requirement |
|---|---|
Decision RPCs (Check, Enumerate, Explain, and their batch forms) | Open — no authenticated principal required. This preserves the simple decision path; a decision is answered fail-closed regardless. |
Entity reads (Get*, List*, ObjectIdentifiers, rule reads, EvaluateRule, Simulate*, ValidateRule) | Require an authenticated principal (they are admin/config reads and tooling). Account-scoped reads (ListPrincipals, ListAccounts, GetGrant, ListGrants) additionally resolve read visibility against the caller’s admin authority. |
Schema mutations (object types, permissions, principals, roles, groups, accounts, rules, templates definitions, Import, Export) | Require system-admin authority (system:*). |
Account-scoped mutations (grants, memberships, BulkPutGrants/BulkDeleteGrants, ApplyTemplate) | Require account-admin authority in the target account (a system-admin supersedes and may drive any account). |
Delegation (Bestow, Revoke) and Impersonation (ImpersonationStart/Stop) | Not routed through the admin gate; each carries its own finer-grained authorization (the delegation subset rule / the impersonation guardrails), where the actor is the delegator / operator, not an admin. |
The actor is always the authenticated principal. For any mutation, the
principal a change is attributed to and authorized against is the identity the
middleware resolved from the request — never a value taken from the request body.
The wire Actor.account field is honoured (it selects the active account), but a
caller cannot act as someone else by editing the body.
The two administrative tiers themselves are ordinary in-scheme authority
(documented in authz/): SYSTEM authority is a holder of system:* (or broader);
ACCOUNT authority is a holder of account:<acct>/admin:* within one account, and
is confined to that account. System supersedes account for account-tier
mutations.
Errors and status codes
Every failure is an APERTURE_* coded error. The handler maps the code onto a
Twirp error code (and thus an HTTP status) and attaches the canonical code as
meta["code"] so a client can dispatch without parsing the message:
| Aperture code (examples) | Twirp code | HTTP |
|---|---|---|
APERTURE_INVALID_INPUT, APERTURE_RULE_INVALID, APERTURE_TEMPLATE_PARAM, … | invalid_argument | 400 |
APERTURE_UNAUTHENTICATED, APERTURE_INVALID_TOKEN | unauthenticated | 401 |
APERTURE_AUTHZ_DENIED, APERTURE_DELEGATION_DENIED, APERTURE_IMPERSONATION_DENIED, … | permission_denied | 403 |
APERTURE_NOT_FOUND, APERTURE_RULE_NOT_FOUND, APERTURE_PROVIDER_UNREGISTERED | not_found | 404 |
APERTURE_UNIMPLEMENTED | unimplemented | 501 |
| anything else | internal | 500 |
See Error Codes for the full registry.
A first call
An anonymous decision needs no credential:
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Check \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","object":"doc:42"}'
{ "allow": true, "reason": "grant g-123 allows read", "deciding_grant_ids": ["g-123"] }
A mutation needs a bearer token that resolves to a principal with the required tier — here, a system-admin creating an object type:
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/PutObjectType \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer root' \
-d '{"actor":{"account":"acme"},"entity_json":"{\"ID\":\"doc\",\"Actions\":[\"read\",\"write\"]}"}'
{}
Related
- RPC reference — the endpoint catalog by area.
- The service facade — the single code path every surface shares.
serve— running the server;--addr,--auth,--store.- Error Codes — the
APERTURE_*registry.
RPC reference
Audience: engineers wiring specific calls against aperture serve.
This is a hand-authored catalog of the ApertureService RPCs, grouped by area.
It summarises each method’s purpose, its request/response messages, and its auth
requirement. The canonical, exact field lists live in
internal/wire/rpc/service.proto
— read it for message shapes; this page is maintained by hand and may lag the
proto (the proto wins on any discrepancy).
Every method is a POST to
/twirp/aperture.ApertureService/<Method> with a JSON body. See the
overview for transport, auth, and error mapping. Auth
shorthand used below:
- open — no authenticated principal required.
- auth — requires an authenticated principal.
- system — requires system-admin authority (
system:*). - account — requires account-admin authority in the target account (system supersedes).
- own rule — not gated by the admin tiers; carries its own delegation / impersonation authorization.
Wire-shape convention
Simple hot-path messages (Check, Enumerate) carry their fields directly.
Rich or recursive shapes — the model entities with their timestamps, the
Explain trace, the rule AST — ride as a canonical JSON string in a *_json
field rather than being modelled in proto. That JSON is identical to the
library’s own encoding of the corresponding model.* struct. So an
EntityRequest.entity_json is just the JSON of a model.ObjectType,
model.Grant, etc., and an EntityResponse.entity_json is the same on the way
back.
Mutations carry an Actor { principal, account }. On the wire the principal is
ignored — the authenticated identity from the middleware is always used —
while account selects the active account. Reads that are account-scoped resolve
their authority from the authenticated principal directly.
Decision RPCs (open)
The core decision API, single and bulk. These are open (no principal required) and always answer fail-closed.
| RPC | Request → Response | Purpose |
|---|---|---|
Check | CheckRequest → Decision | Is principal allowed action on object in account? Returns allow, a reason, and the deciding grant ids. |
CheckBatch | CheckBatchRequest → CheckBatchResponse | Many Checks in one call; results are index-aligned, each either a Decision or a per-item error code+message. |
Enumerate | EnumerateRequest → EnumerateResponse | Which object ids matching pattern may principal take action on? Optional limit. |
EnumerateBatch | EnumerateBatchRequest → EnumerateBatchResponse | Batched Enumerate; index-aligned results. |
Explain | CheckRequest → ExplainResponse | The full decision derivation for a query, as trace_json (the recursive engine Trace, not modelled in proto). |
ExplainBatch | CheckBatchRequest → ExplainBatchResponse | Batched Explain; index-aligned trace_json or per-item error. |
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
-H 'Content-Type: application/json' \
-d '{"account":"acme","principal":"alice","action":"read","pattern":"doc:*","limit":50}'
{ "object_ids": ["doc:42", "doc:77"] }
Entity CRUD
Full create/read/list/delete for each model entity. The write body is
entity_json (a model.* struct as JSON); reads return entity_json (single)
or entities_json (list). List RPCs accept an optional server-side Filter
(field predicates ANDed or ORed) applied before the response is returned.
Writes are system-tier (managing the global schema); reads require auth.
| Entity | Put (system) | Get (auth) | List (auth) | Delete (system) |
|---|---|---|---|---|
| Object type | PutObjectType | GetObjectType | ListObjectTypes | DeleteObjectType |
| Permission | PutPermission | GetPermission | ListPermissions | DeletePermission |
| Principal | PutPrincipal | GetPrincipal | ListPrincipals¹ | DeletePrincipal |
| Role | PutRole | GetRole | ListRoles | DeleteRole |
| Group | PutGroup | GetGroup | ListGroups | DeleteGroup |
| Account | PutAccount | GetAccount | ListAccounts¹ | DeleteAccount |
¹ ListPrincipals and ListAccounts resolve read visibility against the
caller’s admin authority, so an account-admin sees only what their tier permits.
Requests: PutX uses EntityRequest { actor, entity_json }; GetX/DeleteX
use GetRequest/DeleteRequest { actor, id }; ListX uses
ListRequest { filter }. Responses: EntityResponse / EntityListResponse /
Empty.
ObjectIdentifiers (ObjectIdentifiersRequest → ObjectIdentifiersResponse,
auth) enumerates every instance id of an object type from its provider,
optionally minus an exclude list — an admin/config read over all objects of a
type.
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/PutGrant \
-H 'Content-Type: application/json' -H 'Authorization: Bearer acme-admin' \
-d '{"actor":{"account":"acme"},"entity_json":"{\"ID\":\"g-1\",\"Account\":\"acme\",\"Principal\":\"alice\",\"Action\":\"read\",\"Object\":\"doc:*\",\"Effect\":\"allow\"}"}'
Grants and memberships (account-tier)
Grants and memberships are account-scoped: writes require account-admin in the target account; reads resolve against the caller’s authority.
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutGrant | EntityRequest → Empty | account | Create/replace one grant (model.Grant as entity_json). |
GetGrant | GetRequest → EntityResponse | auth | Read one grant, visibility-scoped to the caller. |
ListGrants | ListGrantsRequest → EntityListResponse | auth | Grants in account_id, with optional Filter. |
DeleteGrant | DeleteRequest → Empty | account | Delete one grant by id. |
PutMembership | EntityRequest → Empty | account | Add/replace a principal’s membership in an account. |
DeleteMembership | MembershipKeyRequest → Empty | account | Remove a membership by (principal_id, account_id). |
Rules (definition writes system; reads auth)
Rules are global schema: named, persisted rule-AST definitions the node editor
authors and the rule-backed scope strategies resolve. The AST rides as a
model.Rule JSON in rule_json.
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutRule | RuleRequest → Empty | system | Persist a rule definition. |
GetRule | GetRequest → RuleResponse | auth | Read one rule as rule_json. |
ListRules | Empty → RuleListResponse | auth | Every stored rule, each as JSON. |
DeleteRule | DeleteRequest → Empty | system | Delete a rule by id. |
ValidateRule | RuleRequest → Empty | auth | Compile/validate a rule AST without persisting. Returns Empty on success, an APERTURE_RULE_* coded error (for the canvas) on failure. Touches no storage. |
What-if / simulation (auth)
Read-only previews. Nothing is written and nothing is audited.
| RPC | Request → Response | Purpose |
|---|---|---|
Simulate | SimulateRequest → Decision | The decision a query WOULD get under a hypothetical overlay (unsaved rules + synthetic grants/permissions/principals layered over the live model). Backs the rule editor’s live preview. |
SimulateExplain | SimulateRequest → ExplainResponse | Same overlay, returning the full Explain trace. |
EvaluateRule | EvaluateRuleRequest → EvaluateRuleResponse | Run an UNSAVED rule AST directly against one object’s provider metadata (no account/principal/grant); returns the boolean result plus the object metadata snapshot the rule saw. |
Templates
Reusable grant bundles. Definition writes are system-tier; apply is account-tier (it materialises grants into a target account).
| RPC | Request → Response | Auth | Purpose |
|---|---|---|---|
PutTemplate | EntityRequest → Empty | system | Persist a template definition. |
GetTemplate | TemplateKeyRequest → EntityResponse | auth | Read template (name, version); version <= 0 selects the latest. |
ListTemplates | ListRequest → EntityListResponse | auth | List templates, with optional Filter. |
DeleteTemplate | TemplateKeyRequest → Empty | system | Delete by name/version (version <= 0 deletes all versions). |
ApplyTemplate | ApplyTemplateRequest → EntityListResponse | account | Expand a template transactionally into account, filling params and optionally prefixing generated grant ids; returns the applied grants as JSON. |
Bulk grant / revoke (account-tier)
Transactional multi-grant mutations, both account-tier.
| RPC | Request → Response | Purpose |
|---|---|---|
BulkPutGrants | BulkGrantsRequest → Empty | Create/replace many grants (grants_json) in one transaction. |
BulkDeleteGrants | BulkDeleteGrantsRequest → Empty | Delete many grants by id in one transaction. |
Declarative state (system-tier)
Whole-model portability.
| RPC | Request → Response | Purpose |
|---|---|---|
Export | ExportRequest → ExportResponse | Serialize the entire model to one declarative state file as document_json (a system-tier read). |
Import | ImportRequest → Empty | Apply a state file (document_json) as an idempotent, transactional upsert (the most privileged mutation; system-tier). |
Audit query (gated read)
| RPC | Request → Response | Purpose |
|---|---|---|
QueryAudit | QueryAuditRequest → QueryAuditResponse | The append-only audit events matching a filter, newest first. Records nothing. A system-admin may query the whole trail; an account-admin must set account to their own account (which also gates the read). Filters: filter_actor, account, event_type (mutation/decision/impersonation/delegation), outcome (allow/deny/success/failure), since/until (RFC3339), limit. |
Delegation (own rule)
Not admin-gated; authorized by the delegation subset rule, with the actor = the authenticated delegator.
| RPC | Request → Response | Purpose |
|---|---|---|
Bestow | BestowRequest → Empty | Hand on a subset of the delegator’s own grants (grant_json). |
Revoke | RevokeRequest → Empty | Revoke a previously bestowed grant by id. |
Impersonation (own rule)
Not admin-gated; authorized by the impersonation guardrails, with the actor = the authenticated operator. Sessions are stateless, time-boxed values.
| RPC | Request → Response | Purpose |
|---|---|---|
ImpersonationStart | ImpersonationStartRequest → ImpersonationSession | Begin impersonating target in account under mode (augment or become); returns the session with started_at / expires_at (RFC3339). |
ImpersonationStop | ImpersonationStopRequest → Empty | Discard a session (client-side; echoed for symmetry/audit). |
Related
- RPC / HTTP overview — transport, auth model, error mapping.
service.proto— the canonical contract.- The service facade — the shared code path.
- Error Codes — the
APERTURE_*registry.
MCP surface
Audience: authors integrating Aperture into an MCP client (an AI assistant or agent runtime) as a tool provider.
Aperture exposes its decision API and model inspection as
Model Context Protocol tools. An MCP client
spawns aperture mcp as a subprocess, speaks MCP over stdio, and calls the
Aperture tools the same way it calls any other tool server.
Read-only by construction
The MCP surface is read-only. Every tool calls a read or decision method
on the single service.Service facade — Check / Enumerate / Explain,
the what-if Simulate, and the Get* / List* inspectors. No tool mutates.
No tool name carries a mutating verb (put / create / add / set /
delete / remove / update / write / bestow / revoke / grant / …),
and a test (mcp.TestNoMutatingTool, mirrored at the wire level in the adapter)
fails if one ever does. The aperture mcp command deliberately wires the facade
with storage for inspection and what-if reads but not the gate, delegation,
or impersonation mutators, so the surface cannot write even by accident.
Everything on the surface is a thin translation onto the same facade the CLI and the HTTP/Twirp surface drive — one decision engine, one code path. If a behaviour is not described here it is governed by the facade and documented under The service facade.
The tool catalog
The catalog is stable and ordered. Tool names are aperture_-prefixed,
snake_cased, and defined once in mcp/toolmeta (the single source of truth for
tool identity), so the SDK-free core and the SDK adapter never drift.
Decision API (single + bulk)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_check | Decide whether a principal may take an action on an object, scoped to an account. Returns the verdict (allow/deny), a human-readable reason, and the deciding grant ids. Fail-closed: an operational failure renders as a deny, not an error; only an ill-formed question is an error. | service.Check |
aperture_check_batch | Decide many (account, principal, action, object) questions in one round-trip; results[i] answers queries[i]. A single ill-formed query carries its error in that item without failing the batch. | service.CheckBatch |
aperture_enumerate | List the object ids under a pattern a principal may act on — the inverse of aperture_check. Deny-overrides and specificity are honoured, so a denied object is never returned. | service.Enumerate |
aperture_enumerate_batch | Enumerate accessible objects for many queries in one round-trip, aligned with the input queries. | service.EnumerateBatch |
aperture_explain | Return the full structured decision trace for one question: the expanded subject set, every grant considered with its per-grant outcome, which grants decided, and the final verdict. Use to understand why. | service.Explain |
aperture_explain_batch | Return decision traces for many questions in one round-trip, aligned with the input queries. | service.ExplainBatch |
What-if simulation (read-only, never persisted)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_simulate | Render the full decision trace for a question as it would be under a hypothetical overlay of principals, groups, permissions, grants, and memberships — without writing anything. The overlay is additive; an overlay entity with the same id as a stored one shadows it. Nothing is persisted and nothing is audited. | service.SimulateExplain |
Model inspection
| Tool | Purpose | Maps to |
|---|---|---|
aperture_list_object_types | List every object type with its declared action verb set. | service.ListObjectTypes |
aperture_get_object_type | Fetch one object type by name. | service.GetObjectType |
aperture_list_permissions | List every permission (object-type, action, scope-strategy, delegatable flag). | service.ListPermissions |
aperture_get_permission | Fetch one permission by id. | service.GetPermission |
aperture_list_roles | List every role (named permission bundles). | service.ListRoles |
aperture_get_role | Fetch one role by id, including its permission bundle. | service.GetRole |
aperture_list_groups | List every group (collections of principals usable as grant subjects). | service.ListGroups |
aperture_get_group | Fetch one group by id, including member principal ids. | service.GetGroup |
aperture_list_principals | List every principal (user or machine) with assigned role ids and identity strings. | service.ListPrincipals |
aperture_get_principal | Fetch one principal by id, including assigned roles. | service.GetPrincipal |
aperture_list_grants | List every grant stamped to an account. Account-scoped: a grant in another account is never returned. | service.ListGrants |
aperture_get_grant | Fetch one grant by id (subject, permission, object pattern, effect, account). | service.GetGrant |
Surface documentation
| Tool | Purpose | Maps to |
|---|---|---|
aperture_skills_list | List the embedded skill docs describing how the decision, simulate, and inspection tools fit together. | mcp/skills.List |
aperture_skills_get | Fetch the markdown body of a named skill doc (e.g. mcp-surface), the authoritative reference for driving the surface. | mcp/skills.Get |
Grants are account-scoped by design: aperture_list_grants requires an
account argument and never returns another account’s grants, so the surface
cannot leak cross-account data.
Inputs, outputs, and errors
Each tool carries an input and output JSON Schema (draft 2020-12),
reflected at package-init time from the typed Go contract in mcp/schema.go and
mcp/contract.go. The In/Out types alias the facade’s own surface-neutral types
(for example aperture_check’s input is service.Query and its output is
service.Result), so the schema advertised to the client is exactly the shape
the facade decides on. A client discovers these schemas through the standard MCP
tools/list call — no Aperture-specific schema fetch is needed.
Argument handling and error surfacing:
- Parameterless tools (the
list_*inspectors,aperture_skills_list) accept an empty argument blob. - A missing required argument (for example
idon aget_*tool) returns a plain validation tool-error, surfaced to the model so it can self-correct — it is intentionally not a coded error. - A coded facade error (an
APERTURE_*error) is returned verbatim; the adapter renders it as a structured{code, message, details}envelope rather than a flattened string. - In the batch tools, a per-item failure is folded into that item’s
errorfield (a string); the rest of the batch is unaffected.
The mcp/ core and the SDK firewall
The core lives in the root mcp/ package and is SDK-free: it imports no MCP
protocol SDK. Its files divide the surface cleanly:
| File | Role |
|---|---|
mcp/toolmeta/ | Leaf, pure-data package: the canonical (name, description) table for every tool. Imported by both the core and the adapter so the two never drift. |
contract.go | The typed In/Out structs for every tool (aliasing the facade’s service / engine / model types). |
schema.go | Reflects an input + output JSON Schema for each contract type at init, carried as json.RawMessage. |
handlers.go | One typed func(ctx, *service.Service, In) (Out, error) per tool; each calls exactly one facade read/decision method. |
tools.go | Type-erases handlers into a ToolDescriptor catalog (Tools(cfg)), pairing each name with its reflected schemas and an Invoke closure. |
The core depends only on the decision facade (service), the domain types it
returns (engine, model), the coded-errors package (errors), the embedded
skill docs (mcp/skills, stdlib-only), and the schema reflector
(google/jsonschema-go). It carries the schema as json.RawMessage
specifically so a consumer can import the contract without pulling in any MCP
SDK.
The adapter is the only SDK importer
The one package permitted to import the protocol SDK
(github.com/modelcontextprotocol/go-sdk) is the thin mcp/gosdk adapter.
gosdk.Register(server, svc, cfg) mounts the core’s ToolDescriptor catalog
onto a caller-supplied go-sdk server via the low-level Server.AddTool path —
which accepts any value that marshals to a valid 2020-12 schema, exactly the
json.RawMessage the core emits. Register mounts onto the server it is given;
it never constructs, serves, or owns the server lifecycle. An embedder that
already runs its own go-sdk server can mount the full Aperture surface the same
way.
firewall_test.go makes the guarantee load-bearing
mcp/firewall_test.go enforces the boundary so it is a fact, not an aspiration:
TestMCPCore_NoSDKImportrunsgo list -depsover the core packages (mcp,mcp/toolmeta,mcp/skills) and fails if the MCP SDK module appears anywhere in their transitive dependency graph. Moving an SDK import into any core package makes this test fail with the exact offending dependency line.TestMCPCore_AllowedDepsReachableasserts the documented allow-list (service,engine,model,errors,mcp/skills,mcp/toolmeta,google/jsonschema-go) is reachable from the core, proving the firewall is inspecting a real, populated graph rather than passing vacuously.
The adapter (mcp/gosdk) is deliberately excluded from the firewall’s package
list — the SDK is allowed there and nowhere else. This keeps the promise a client
author relies on: importing the Aperture MCP contract never drags in a protocol
SDK.
Running it
aperture mcp hand-wires the read-only dependency graph
(storage → engine → service), constructs the go-sdk server, mounts the SDK-free
catalog through the gosdk adapter, and serves over stdio — the transport an
MCP client uses when it spawns Aperture as a subprocess:
aperture mcp [--seed <path>] [--store <dsn>]
--seed— path to a JSON/YAML seed model (defaults to the embedded example).--store— sqlite DSN for the backing store (defaults to in-memory).
With neither flag it serves the embedded example model over an in-memory store. There are no other flags: the surface is read-only by construction, so it needs no acting principal, account, or auth adapter.
On start it prints one line to stderr — stdout is reserved for the MCP protocol:
aperture mcp: serving read-only MCP surface over stdio
Connecting a client
You normally don’t launch aperture mcp interactively; an MCP client spawns it
over stdio. A minimal client configuration points at the binary and the model to
serve:
{
"mcpServers": {
"aperture": {
"command": "bin/aperture",
"args": ["mcp", "--store", "./aperture.db"]
}
}
}
The server advertises its identity as aperture with the binary’s build version
during the MCP initialize handshake, then answers tools/list with the full
catalog above and tools/call for each tool.
Related
aperture mcp— the command-level reference and flags.- The service facade — the one code path behind every tool.
- Decision API — the same check/enumerate/explain questions from Go.
- RPC / HTTP overview — the read/write Twirp surface served by
aperture serve.
Admin UI shell
Audience: operators who want to browse and edit the access-control model from a browser instead of the CLI or raw RPC.
Aperture ships a small embedded admin UI — a static, single-page frontend
served from the root of a running aperture serve. It is not a separate service:
the HTML, CSS, and JavaScript are compiled into the binary and mounted at /, so
starting the server is all it takes to reach the UI. Every screen is a thin client
over the same Twirp / HTTP surface; the shell holds no
access-control logic of its own — it decodes what the server returns and posts
mutations back through the facade.
What it is built from
The shell is deliberately dependency-light and builds with no Node pipeline. Its
assets are pre-built, committed blobs embedded via //go:embed all:static (see
internal/server/static.go), so the binary stays self-contained in dev and CI.
| Layer | Role in the shell |
|---|---|
| Alpine.js | Small reactive framework driving each screen’s component and the hash-routed navigation. |
| BERA design tokens | The house design-token system. The shell’s authoritative styling is the hand-authored static/css/bera.css, written from the token spec; it is not Tailwind/DaisyUI. |
| Tailwind CSS / DaisyUI | Vendored utility/component CSS carried for the domain screens. |
| Rete.js | A node-graph editing library. The Rules screen mounts a Rete canvas to edit rule blueprints; the bundle is loaded at runtime from /vendor/rete/rete.min.js. |
The full frontend lives under internal/server/static/ (index.html, css/,
js/, and vendor/). Vendored blobs (Alpine, Tailwind, DaisyUI) are pinned and
documented in internal/server/static/vendor/README.md. The Rete.js bundle is
the one asset regenerated by a manual make vendor-rete target — it is never
rebuilt in CI. Treat all of these as committed artifacts, not build outputs.
How to reach it
Start the server and open the listen address in a browser:
aperture serve # listens on :8080 by default
- Default address:
:8080(override with--addr). See theservecommand for all flags. - The UI is mounted last on the mux at
/. More specific routes — the Twirp path prefix,POST /check,GET /healthz— take precedence, so the frontend never shadows the API.
Auth expectation
Aperture’s authentication is external — the shell never issues credentials.
Every request the UI makes is routed through a single apiFetch wrapper that
attaches an Authorization: Bearer <token> header, and a 401 clears the token
and re-opens the sign-in affordance.
With the default dev authenticator (aperture serve with no --auth
override), the bearer token is the principal id: “signing in” through the UI
just names which principal your session presents. Switching to the oidc or
parsec adapters changes how that bearer is obtained, but the shell’s behaviour
is unchanged — it still only carries whatever token it holds. A global account
switcher in the top bar picks the active account shared by every screen; the
server scopes the account list to what the signed-in principal may see
(ListAccounts).
Screen tour
The left-hand nav routes by URL hash (#/crud, #/grants, …). Each screen is
one Alpine component in static/js/, and each drives a small set of Twirp RPCs
on aperture.ApertureService.
Model (CRUD) — #/crud
The model editor. Browse and mutate the core entities — principals, roles,
groups, permissions, object types, and accounts — plus account memberships. It
lists each entity type (ListPrincipals, ListRoles, ListGroups,
ListPermissions, ListObjectTypes, ListAccounts), creates/updates via the
matching Put*, and removes via Delete*; memberships use
PutMembership / DeleteMembership. It probes the viewer’s admin tier with the
open Check RPC and can Export the model.
Grants — #/grants
Bestowed grants, delegation, and grant templates. Lists and edits grants
(ListGrants, PutGrant, DeleteGrant), issues and rescinds authority
(Bestow, Revoke), and manages templates (ListTemplates, PutTemplate,
DeleteTemplate, ApplyTemplate) with a bulk path (BulkPutGrants). It uses
Check to resolve the viewer’s tier.
Graph — #/graph
A force-directed map of the whole model rendered on a <canvas>. It pulls the
entire declarative model with a single Export RPC (seeding an account via
ListAccounts first) and derives every node and edge — subjects, grants,
objects, memberships, roles, and reachability — from that one document, so the
picture always reflects the live store. The layout is a small self-contained
simulation; no external graph library is used here.
Rules — #/rules
The rule-blueprint editor. This is where Rete.js is used: rules are edited as
a node graph on a Rete canvas, then serialized to Aperture’s rule AST. It loads
and saves rules (ListRules, GetRule, PutRule), lists object types
(ListObjectTypes) and object identifiers (ObjectIdentifiers), and validates
or dry-runs an expression against the live model (ValidateRule,
EvaluateRule, Check).
What-if — #/whatif
A read-only decision simulator. Ask “would principal P be allowed action A on
object O?” and see both the verdict and the full explain trace — the expanded
subject set, every grant considered with its per-grant outcome, and the deciding
grant(s). It drives the open Check and Explain RPCs (no mutation), listing
accounts via ListAccounts. It runs a hypothetical against the live model
without writing anything.
Audit — #/audit
A queryable table over the append-only audit trail via the QueryAudit RPC.
Every mutation, impersonation, and delegation is always recorded; decisions are
sampled. Read access is tier-gated — a system-admin reads everything, an
account-admin reads only events scoped to their own account — and the screen
probes both tiers with Check (using ListAccounts for scope). A viewer with
neither tier is told why the table is empty.
Import / export (portability) — #/portability
Download the declarative model file or import one with a preview diff. It exports
the whole model (Export), and imports a supplied document (Import) after
resolving the viewer’s tier with Check and scoping with ListAccounts.
Related
- RPC / HTTP overview and the RPC reference — the surface every screen calls.
servecommand — flags, listen address, and auth adapter.- The service facade — the one code path behind every RPC the UI drives.
Identity patterns & specificity
Everything Aperture decides is expressed in one primitive: a uniform,
hierarchical identity. The same type describes principals (the actor a
decision is made for) and objects (the resource an action targets), so there
is no second addressing scheme to learn. This chapter defines that primitive,
the pattern grammar a grant uses to cover many identities at once, the
Contains containment test delegation relies on, and the specificity score
that resolves overlapping matches.
If you have not read the Concepts primer, skim
it first — it fixes the vocabulary (principal, object, grant, effect) this page
builds on. The code lives in the identity package.
The identity primitive
An identity is an ordered path of typed segments. Each segment is a
type:id pair; segments are joined by /:
account:acme/project:atlas/document:42
Read left to right, each segment narrows the path: account acme, then project
atlas within it, then document 42 within that. A principal is written the
same way — user:alice or account:acme/team:eng — because principals and
objects share the type.
The grammar is intentionally strict, and matching runs on the Check hot path
(the NFR target is p99 < 1 ms), so parsing validates without regular
expressions and stores segments pre-split so a match never re-parses:
- A segment’s type and id are each non-empty.
- Allowed characters in a component are ASCII letters, digits, and
-._~@+— enough for slugs, UUIDs, and qualified ids, while excluding the structural delimiters/and:, whitespace, and the*wildcard sentinel. - An empty string, an empty segment (a leading, trailing, or doubled
/), a segment missing its:, or an illegal character is rejected as anAPERTURE_IDENTITY_INVALIDcoded error.
identity.Parse builds an Identity from its canonical string and round-trips
losslessly (Parse(s).String() == s); identity.New builds one from segments a
caller already holds. The identity package depends on nothing but the root
errors/ package, so it stays a leaf every other layer resolves against.
id, err := identity.Parse("account:acme/project:atlas/document:42")
if err != nil {
// APERTURE_IDENTITY_INVALID
}
id.Len() // 3
id.Segments() // []Segment{{account,acme}, {project,atlas}, {document,42}}
Patterns
A single grant rarely names one object. A pattern is an identity with
wildcards that describes a set of concrete identities; a grant’s object scope
is a pattern. Build one with identity.ParsePattern. Beyond the concrete-identity
grammar, a pattern segment may be:
| Form | Meaning |
|---|---|
type:id | a literal segment — matches exactly one segment with that type and id |
type:* / *:id | a component wildcard — * in place of a type or id matches any value for that component |
type:{a,b,c} | a component set — matches any of the listed values (e.g. brand:{1,5,23}) |
* | a single wildcard segment — matches exactly one segment of any type and id |
** | a double wildcard segment — matches one-or-more segments recursively |
A component set lets one grant scope to several ids without a wildcard, so
brand:{1,5,23} covers exactly three brands and nothing else. The set must be
non-empty ({} is rejected); duplicate members are dropped, preserving order.
How a pattern matches
Pattern.Matches(identity) is anchored at both ends: every identity segment
must be consumed and every pattern segment satisfied. A literal or single *
each consume exactly one identity segment; ** consumes one-or-more via
backtracking. The matcher slices into the pre-compiled segments and does not
allocate.
Pattern Matches?
account:acme/project:atlas/** account:acme/project:atlas/document:42 ✓ (** = "document:42")
account:acme/project:atlas/** account:acme/project:atlas ✗ (** needs ≥1 segment)
account:acme/project:*/document:42 account:acme/project:atlas/document:42 ✓ (project id wildcard)
account:acme/brand:{1,5,23} account:acme/brand:5 ✓ (5 ∈ set)
account:acme/brand:{1,5,23} account:acme/brand:9 ✗ (9 ∉ set)
Because ** must consume at least one segment, a pattern is never shorter
than the identities it matches minus its wildcard expansion — a trailing **
matches any non-empty remainder but not the empty one.
Enumerating a finite pattern
When a pattern contains no * or ** anywhere — every segment is a literal
whose components are plain literals or explicit sets — its language is finite.
Pattern.Expand() returns that concrete set as the deterministic cross-product
of each segment’s members (segment order, then written member order), reporting
ok=false for any pattern with a wildcard (whose language is unbounded or
provider-dependent). This lets a set-scoped grant like brand:{1,5,23} list its
three concrete objects without consulting a provider.
Specificity: resolving overlaps
Two grants can both match the same object with different effects — a broad
allow on account:acme/project:atlas/** and a narrow deny on
account:acme/project:atlas/document:secret. Aperture resolves this with
deny-overrides ranked by specificity: the more specific pattern wins, and on
a genuine tie deny beats allow. identity.Specificity(pattern) produces the
deterministic score that drives the tiebreak.
The score is a single weighted integer (paths are short, so there is no overflow risk) built from three signals:
| Signal | Weight | Rationale |
|---|---|---|
| literal components (a fixed type or id) | 10000 each | a pinned value is the strongest specificity signal |
| total segment count | 100 each | a longer pinned path breaks ties among equal literal counts |
each ** | −1000 penalty | a recursive wildcard matches a variable-length span, the least specific construct |
A type:id literal contributes up to two literal components; a component
wildcard (*) or a bare * segment contributes none (only its pinned
position). The result is that the pattern a human would call “more specific” —
more fixed components, fewer and shallower wildcards — always scores strictly
higher.
Pattern Specificity
account:acme/project:atlas/document:secret 6 literals·10000 + 3 segs·100 = 60300
account:acme/project:atlas/** 4 literals·10000 + 3 segs·100 − 1000 = 39300
account:acme/** 2 literals·10000 + 2 segs·100 − 1000 = 19200
identity.Compare(a, b) returns +1 / -1 / 0 as a total, deterministic order
(safe as a sort key), and identity.MoreSpecific(a, b) reports whether a
ranks strictly above b. The decision engine consumes these directly; this
package never applies deny-overrides itself — it only supplies the ranking.
flowchart TD
O["object: account:acme/project:atlas/document:secret"]
O --> G1["grant A · allow<br/>account:acme/project:atlas/** · spec 39300"]
O --> G2["grant B · deny<br/>account:acme/…/document:secret · spec 60300"]
G1 --> R{"both match —<br/>rank by specificity"}
G2 --> R
R -->|"B is more specific"| D["deny wins"]
Containment: Contains
Delegation asks a different question: is one grant’s authority a subset of
another’s? identity.Contains(outer, inner) answers it structurally — it
reports whether outer’s language is a superset of inner’s, i.e. every
concrete identity inner matches, outer matches too. Equivalently, inner is
“equal-or-more-specific / contained within” outer. outer.Contains(inner) is
the method form.
The test is sound and conservative: it never reports containment that does
not hold, but — because it is a structural check rather than a full language
inclusion decision over the one-or-more ** — it may return false for some
genuinely-contained pairs. Delegation fails closed, so a false negative merely
rejects a bestow that could in principle have been allowed; it never permits an
escalation. It is reflexive (a pattern contains itself).
Contains(account:acme/**, account:acme/project:atlas/document:42) = true
Contains(account:acme/brand:{1,5}, account:acme/brand:5) = true
Contains(account:acme/brand:{1,5}, account:acme/brand:{5,9}) = false (9 ∉ {1,5})
Contains(account:acme/*, account:acme/**) = false (single can't cover ≥1)
For a component, an outer wildcard covers anything, an inner wildcard is never
subsumed by a fixed outer, and otherwise both reduce to finite value sets where
outer must contain every inner value — so {1,5,23} subsumes 5 and {1,5}
but not 3 or {5,9}.
Where this leads
Patterns bound what a grant covers, but membership within that bound can be more than literal matching — a grant can cover “every object of this type” or “these ids only” or “whatever a rule selects.” That is the job of scopes and scope strategies. The attributes a rule reads about an object are supplied by the host through providers. For the end-to-end decision flow that consumes specificity, see the library Decision API.
Rules engine
A grant can be gated on the attributes
of a request — deny a read unless the object’s classification is public,
allow a share only for principals above a clearance tier. Aperture expresses
those conditions as rules. A rule is a small, typed AST that Aperture
compiles once to an expression program and evaluates in-process against the
object’s metadata plus the principal/action context.
Aperture evaluates rules with
expr-lang/exprdirectly. It renders each rule AST to an expr-lang expression and compiles it with expr-lang’s pure-Go evaluator, in-process. There is no external policy service and no dependency on Pulse — any documentation that says “Pulse expression” is stale. The rules package importsgithub.com/expr-lang/expr; that is the whole engine.
The code lives in the rules package, in three layers: the AST (ast.go), the
compiler and cache (compiler.go, cache.go), and the engine (engine.go).
The rule AST
A rule is a tree of rules.Node values. The node set is deliberately small
and closed so a node editor can map its palette one-to-one onto it, and so the
JSON form is a stable contract that round-trips byte-identically
(marshal → unmarshal → marshal). There is no second rule format.
NodeType | Fields used | Meaning |
|---|---|---|
and | Children (≥ 2) | logical conjunction |
or | Children (≥ 2) | logical disjunction |
not | Children (exactly 1) | logical negation |
compare | Op, Left, Right | binary comparison |
var | Name (dotted path) | a context-variable reference |
literal | Value (scalar JSON) | a string, number, bool, or null constant |
list | Items | an ordered list, the right side of in/nin |
call | Name, Items (args) | a call to a registered pure function |
The comparison operators carried in compare.Op are eq ne lt le gt ge in nin,
rendered to == != < <= > >= in "not in" respectively.
Constructor helpers build the tree in Go — And, Or, Not, Compare, Var,
Lit, List, Call. This rule says the object is public, or the principal’s
tier is one of gold/platinum:
ast := rules.Or(
rules.Compare(rules.OpEq, rules.Var("object.classification"), rules.Lit("public")),
rules.Compare(rules.OpIn, rules.Var("principal.tier"),
rules.List(rules.Lit("gold"), rules.Lit("platinum"))),
)
Its canonical JSON — the shape the editor and the state file persist — omits every zero field, keeping the serialized form minimal:
{
"type": "or",
"children": [
{"type": "compare", "op": "eq",
"left": {"type": "var", "name": "object.classification"},
"right": {"type": "literal", "value": "public"}},
{"type": "compare", "op": "in",
"left": {"type": "var", "name": "principal.tier"},
"right": {"type": "list", "items": [
{"type": "literal", "value": "gold"},
{"type": "literal", "value": "platinum"}]}}
]
}
The evaluation context
A rule reads from a closed set of four roots, and only those — a reference to anything else is an unknown variable:
| Root | Type | Contents |
|---|---|---|
object | map | the object’s metadata snapshot (host-defined fields, e.g. object.classification) |
principal | map | the principal’s attribute bag (e.g. principal.tier); default exposes only principal.id |
account | map | account attributes |
action | string | the action verb (action == "read") |
The three metadata roots are map[string]any so a rule reads host-defined
fields dynamically; action is a typed string so misusing it (action.foo) is
a type error. This closed environment is enforced twice — structurally by
Validate (below) and again by the expr-lang type-checker at compile time.
Validation
Node.Validate() checks that a node and its subtree are structurally
well-formed — the closed node set, the correct arities (and/or need ≥ 2
children, not exactly 1, compare a left and right operand), a known
comparison operator, a scalar literal, and a variable whose first path segment
is one of the four roots. It returns APERTURE_RULE_INVALID for a malformed node
and APERTURE_RULE_UNKNOWN_VARIABLE for a variable outside the exposed roots.
Validation is pure structure — it does not type-check and never touches the
expression engine. A literal is checked to carry a scalar (arrays and objects are
rejected; use a list node for collections). An in/nin right operand must be
a list or a var.
For the deep check — structure plus a full compile pass that surfaces type
errors and unknown functions — call rules.ValidateAST(raw). It decodes a JSON
AST and compiles it against a shared package-level validator (nil source, nil
fetcher — it resolves no references and fetches no metadata), returning nil for a
compilable rule and an APERTURE_RULE_* code otherwise. This is what a
save/validate surface runs before persisting a rule.
Compilation and caching
Node.Expr() renders the validated AST to an expr-lang expression string. The
rendering is direct and injection-free (variable paths are Go-style identifiers,
string literals are quoted, integers keep their exact form via json.Number):
((object.classification == "public") || (principal.tier in ["gold", "platinum"]))
Compiler.Compile(node) validates, renders, and compiles that expression to a
reusable *vm.Program via expr.Compile. Every compiler fixes the same options:
expr.Env(evalEnv{})— the typed four-root environment, so any other top-level identifier is an unknown name at compile time.expr.AsBool()— a rule must evaluate to a boolean.expr.DisableAllBuiltins()— all of expr-lang’s builtins are off, so no wall-clock or random function is reachable and evaluation stays deterministic.- The curated pure function set —
lower,upper,contains,startsWith,endsWith,len. A host adds its own deterministic, side-effect-free functions withrules.Function(name, fn)/Engine WithFunction; these join (and can shadow) the curated set. An unknown function is caught at compile time.
A compile failure that survives validation is a type mismatch, a non-boolean
result, or a call to an unregistered function — all surfaced as
APERTURE_RULE_TYPE_ERROR, with the evaluator’s own message preserved in context.
A Compiled is immutable and safe for concurrent evaluation; it carries the
canonical Source() and its Hash() (sha256 of the source).
The compiled-rule cache
Compiling an expression is the expensive step, and Check runs on a tight
latency budget, so a compile happens once per canonical form. The engine keys
a compiledCache by the rule’s canonical hash. Two different rule references
whose ASTs render to the same expression share a single compiled program.
flowchart TD
A["rule AST (Node)"] --> V["Validate<br/>structure + roots"]
V --> R["render → expr-lang source"]
R --> H["hash = sha256(source)"]
H --> C{"cache hit<br/>for hash?"}
C -->|"hit"| P["reuse *vm.Program"]
C -->|"miss"| K["expr.Compile → program"]
K --> S["cache.put"]
S --> P
P --> E["Eval(Input) → bool"]
The cache is concurrency-safe and exposes Hits / Misses / Evictions / Entries
via Engine.CacheStats(). An optional TTL (WithCacheTTL, with an injectable
Clock for deterministic tests) bounds entry lifetime; TTL ≤ 0 keeps entries
until explicit invalidation. When a rule’s definition changes underneath a cached
compilation, a host calls Engine.Invalidate(node) (drop one) or
Engine.InvalidateAll() (clear).
Evaluation and the engine
Compiled.Eval(ctx, Input) runs the program against an Input — Object,
Principal, and Account maps plus the Action string (a nil map reads as
empty). Evaluation is pure: it reads only the input, mutates nothing (the
object metadata snapshot is treated read-only), and exposes no nondeterministic
function. A runtime failure or a non-boolean result is APERTURE_RULE_EVAL.
rules.Engine ties the pieces together. It is built over a RuleSource (which
resolves an opaque rule reference to its Rule definition — MapSource is the
in-memory default; a missing reference yields APERTURE_RULE_NOT_FOUND) and a
MetadataFetcher (whose signature matches *provider.Registry.Fetch, so a
provider registry wires in directly as the object-metadata
source without the rules package importing provider). An optional
PrincipalResolver supplies principal attributes; the default exposes only
principal.id.
Engine.Selected(ctx, rule, object, principal, action) is the full path:
- resolve the rule reference through the
RuleSource; - compile-and-cache its AST;
- fetch the object’s metadata (empty when no fetcher is configured);
- resolve the principal’s attributes;
- build the
Inputand evaluate.
Any step’s failure is an APERTURE_* coded error, and the caller treats it as a
non-decision — there is no select-on-error. That signature is exactly
scope.RuleEvaluator, which is how the rule-backed inclusive/exclusive
scope strategies get their variant: the engine is wired as
scope.Deps{Rules: engine}.
Where this leads
Rules are one of two ways a scope strategy can decide object membership; the
other is an explicit id-list. See scopes & scope strategies for how
the inclusive and exclusive strategies consult a rule. The object.* fields a
rule reads come from providers.
Scopes & scope strategies
A grant’s pattern bounds which objects the grant can reach. But
“reach” is not always “every match” — a grant might cover every object of a
type in that scope, or only a named id-list, or everything except a few,
or whatever a rule selects. That choice is the grant’s scope
strategy, and this chapter covers the pluggable resolvers that implement it.
The code lives in the scope package.
The key separation: the pattern bounds the scope and supplies the specificity the engine’s deny-overrides tiebreak consumes; the strategy decides membership within that bound. A resolver never computes specificity — that stays the pattern’s job in the engine, so the resolution semantics from literal-only grants are untouched.
The four strategies
| Strategy | Membership within the pattern scope |
|---|---|
literal | exactly the objects the pattern matches (the baseline) |
implicit | every object of the permission’s type in scope — unfettered |
inclusive | opt-in: only an explicit id-list, or objects a rule selects |
exclusive | opt-out: all-of-type in scope except an id-list, or objects a rule excludes |
literal is owned natively by the decision engine and is not in the
registry. The scope package ships the other three plus a registry for host-defined
strategies. Membership always composes with the pattern: a listed object that
falls outside the grant pattern is not covered.
The scope reference and Spec
A permission carries its strategy as an opaque string reference; the engine parses
it into a typed scope.Spec with ParseSpec. The grammar is a strategy key
optionally followed by ;-separated name=value params. Identity strings never
contain ;, =, or ,, so those separators never collide with id-list values:
implicit
inclusive;ids=account:acme/document:42,account:acme/document:99
exclusive;ids=account:acme/document:7
inclusive;rule=quarantine-rule
ParseSpec yields Spec{Strategy, IDs, Rule}. An empty reference (or the
explicit literal) parses to the literal strategy, so grants that carry no
strategy keep their literal behaviour. Parsing validates structure only —
known params (ids, rule), non-empty values, no duplicates — and does not
consult the registry, so a custom strategy key parses cleanly and is resolved by
whatever the host registered. Anything malformed is APERTURE_SCOPE_INVALID.
The resolver contract
Each strategy is a ScopeResolver, constructed per evaluation from a
GrantContext (the already-parsed pattern, the permission’s object type, the
parsed Spec, and the principal/action context) plus runtime Deps:
type ScopeResolver interface {
// hot path — "is this concrete object a member?" — never enumerates.
Contains(ctx context.Context, object identity.Identity) (bool, error)
// bounded enumeration for Enumerate-style callers.
Members(ctx context.Context, pattern identity.Pattern) ([]identity.Identity, error)
}
Contains answers the Check hot-path question and never needs to list objects.
Members performs a bounded enumeration (capped by DefaultMaxMembers =
1000) for Enumerate-style callers. Resolver construction is cheap — small value
structs, id-list membership by linear scan, no per-evaluation map allocation — and
holds no cache.
Two seam dependencies
Strategies that need to enumerate “all objects of a type”, or evaluate a rule,
reach two seams through scope.Deps. Both default to an inert implementation
so a zero Deps is usable:
| Seam | Supplied by | Default behaviour |
|---|---|---|
ObjectLister | the provider Registry (*Registry matches its signature byte-for-byte) | APERTURE_SCOPE_LISTER_UNCONFIGURED |
RuleEvaluator | the rules Engine (*rules.Engine satisfies it) | APERTURE_SCOPE_RULE_UNCONFIGURED |
RuleEvaluator.Selected(ctx, rule, object, principal, action) is exactly
rules.Engine.Selected — that shared signature is how the rule-backed path is
wired without scope importing rules.
How each strategy decides Contains
flowchart TD
O["object"] --> M{"pattern matches?"}
M -->|no| N["not a member"]
M -->|yes| S{"strategy"}
S -->|implicit| T1{"terminal type ==<br/>object type?"}
S -->|inclusive| L1{"in id-list?"}
S -->|exclusive| T2{"terminal type match?"}
T1 -->|yes| Y["member"]
T1 -->|no| N
L1 -->|yes| Y
L1 -->|"no, rule set"| RS{"rule Selected?"}
L1 -->|"no, no rule"| N
RS -->|yes| Y
RS -->|no| N
T2 -->|no| N
T2 -->|"yes, in minus-list"| N
T2 -->|"yes, rule excludes"| N
T2 -->|"yes, otherwise"| Y
implicit — membership is the conjunction of the pattern match and a
terminal-type check (the object’s last segment is of the permission’s object
type). It takes no configuration; supplying an ids list or a rule is a
misconfiguration and is rejected so it cannot silently mask intent. Members
must list the type, so it depends on the ObjectLister.
inclusive — an opt-in. It must declare an id-list or a rule; neither is a
misconfiguration. Contains first requires the pattern match, then: the object’s
canonical id is in the list (exact string equality), or — only when a rule is
declared — the RuleEvaluator selects it. A pure list-backed grant never touches
the rule dependency. Members on the list path needs no lister (the members are
the listed ids within both patterns); a rule-only inclusive grant cannot
enumerate without the evaluator’s reverse index and reports
APERTURE_SCOPE_RULE_UNCONFIGURED.
exclusive — an opt-out. It requires a minus id-list or a rule (declaring
neither would make it identical to implicit). Contains requires the pattern
match and the terminal-type check, then is a member unless the object is in
the minus-list or the rule excludes it. Members enumerates all-of-type in scope
via the ObjectLister and drops the excluded ones through Contains.
The registry
scope.Registry maps strategy keys to Factory functions. DefaultRegistry()
preloads the three built-ins (implicit, inclusive, exclusive); literal
stays native to the engine and is intentionally absent. A host adds its own with
Register / MustRegister (rejecting an empty key, a nil factory, or a
duplicate with APERTURE_SCOPE_INVALID).
Registry.Resolve(grantContext, deps) builds the resolver for the spec’s
strategy, validating the spec via the strategy’s factory. An unregistered
strategy yields APERTURE_SCOPE_UNKNOWN_STRATEGY; a spec the strategy rejects
yields APERTURE_SCOPE_INVALID.
reg := scope.DefaultRegistry()
spec, _ := scope.ParseSpec("exclusive;ids=account:acme/document:7")
gc := scope.GrantContext{
Pattern: identity.MustParsePattern("account:acme/document:*"),
ObjectType: "document",
Spec: spec,
Principal: "user:alice",
Action: "read",
}
resolver, _ := reg.Resolve(gc, scope.Deps{Lister: providerReg /* , Rules: rulesEngine */})
ok, _ := resolver.Contains(ctx, identity.MustParse("account:acme/document:42"))
// true: matches the pattern, is a document, and is not in the minus-list.
Where this leads
The ObjectLister that implicit and exclusive enumeration depend on is the
provider Registry; the RuleEvaluator the rule-backed paths
consult is the rules Engine. For how the decision engine assembles
grants, resolvers, and specificity into a verdict, see the
library Decision API.
Providers
A rule reads object.classification; an exclusive scope
enumerates “every document in this account.” Both need domain data Aperture
does not own. That data belongs to the host application — its database, its
API, its source of truth — and Aperture reaches it through providers. A host
implements one ObjectProvider per object-type; a Registry binds each type to
its provider plus a per-type cache and is the seam every consumer resolves
through. The code lives in the provider package; csvprovider is a concrete
worked example.
A load-bearing rule: Aperture never persists provider data as a source of
truth. The host owns it; Aperture only ever caches a copy. Cached metadata is
handed back by reference and treated read-only — the cache never copies a map
on read (allocation matters on the Check hot path), so a provider must return a
fresh map per object and callers must never write to a returned map.
ObjectProvider: the host seam
A host implements this once per object-type. It is a pull source — Aperture asks, the host answers — and must be safe for concurrent use:
type ObjectProvider interface {
Fetch(ctx context.Context, id identity.Identity) (Metadata, error)
List(ctx context.Context) ([]Object, error)
Query(ctx context.Context, filter Filter) ([]Object, error)
}
Fetchreturns one object’s metadata; a missing object yields anAPERTURE_NOT_FOUNDcoded error, so the Registry can tell “absent” from an operational failure.Listis the unfiltered enumeration of the type.Queryreturns the objects matching aFilter— an optionalPattern(bounds results to matching identities), host-interpretedFieldspredicates, and aLimit. The zeroFilterselects everything (equivalent toList).
Metadata is map[string]any — an alias, not a named type — so the rules
engine reads each field straight into its expression environment with no
conversion layer. An Object pairs an identity with its metadata; the identity’s
terminal segment type is the object-type the provider is registered under.
The Registry: binding, cache, invalidation
provider.NewRegistry() returns an empty registry; Register(objectType, provider, opts...) binds a provider to a type with a per-type cache (rejecting an
empty type, a nil provider, or a duplicate with APERTURE_PROVIDER_INVALID). The
registry is concurrency-safe: providers register at startup and are read on the
hot path under an RWMutex, and each per-type cache is independently safe.
Registry.Fetch(ctx, id) is the read path consumers use. It routes by the id’s
terminal segment type (an unregistered type is APERTURE_PROVIDER_UNREGISTERED),
serves from the type’s cache when fresh, and otherwise pulls through the provider
and caches the result — a cache hit never calls the provider. A host provider’s
error is normalised by providerError: one already carrying an APERTURE_* code
passes through verbatim (so its APERTURE_NOT_FOUND reaches the caller intact),
while a plain error is wrapped as APERTURE_PROVIDER_FETCH.
flowchart TD
C["consumer: engine / rules / scope"] --> F["Registry.Fetch(id)"]
F --> T["route by terminal type"]
T --> H{"cache hit<br/>& fresh?"}
H -->|yes| R["return cached metadata (read-only)"]
H -->|no| P["ObjectProvider.Fetch"]
P --> S["cache.Set"]
S --> R
The registry serves two other roles by matching contracts from other packages without importing them:
Fetchis arules.MetadataFetcher— its signature is exactly what the rules Engine wants for object metadata, so a*Registryis wired in as the fetcher directly.List(ctx, objectType, pattern, limit)is ascope.ObjectLister— byte-for-byte the seam the implicit/exclusive scope resolvers left open, so a*Registryis passed asengine.ScopeDeps{Lister: reg}. It queries the provider, bounds the result by the pattern and the limit (DefaultListLimit= 1000), and opportunistically warms the cache with each returned object’s metadata.
Two enumeration variants sit beside the bounded List: Identifiers returns the
complete, unbounded id set (sorted, for a stable diff — use it to expand an
exclusive allowance into a positive allow-list), and IdentifiersExcept is
Identifiers minus an excluded set.
Cache tuning and invalidation
Each type’s cache is an in-memory LRU (MemoryCache) behind the pluggable
CacheBackend interface, tuned per type at registration:
| Option | Default | Effect |
|---|---|---|
WithTTL(d) | DefaultTTL = 30s | freshness window; d ≤ 0 disables expiry |
WithMaxSize(n) | DefaultMaxSize = 10 000 | LRU cap; n ≤ 0 means unbounded |
WithClock(now) | time.Now | injectable clock for deterministic TTL tests |
Invalidation is explicit: Invalidate(id) drops one object, InvalidateType
clears a type, InvalidateAll clears every cache. Stats(objectType) exposes
Hits / Misses / Evictions / Expirations / Invalidations / Entries for
observability and the latency benchmark. The provider package depends only on
identity and errors — never scope, engine, or model — so it stays a leaf.
Worked example: csvprovider
csvprovider implements ObjectProvider over a CSV file, so a host can wire real
object data during development before a database-backed provider exists. It is a
drop-in adapter: register a *Provider under an object-type exactly as a future
SQL-backed provider would be, and the Registry’s cache, invalidation, and rules
wiring are unchanged.
reg := provider.NewRegistry()
reg.MustRegister("brand", csvprovider.New("brands.csv"), provider.WithTTL(0))
reg.MustRegister("app", csvprovider.New("apps.csv"), provider.WithTTL(0))
// swapping to a database later changes only these two lines.
File shape
The first row is a header. One column must be named id and holds each
object’s canonical identity string; its terminal segment type is the object-type
the provider is registered under. Every other column becomes a metadata field
keyed by the column name. A column name may carry a name:type suffix so its
cells are coerced to a real type the rules engine reads natively:
id,category_id,seats:int,active:bool,budget:float
brand:1,electronics,40,true,15000.50
brand:5,books,12,false,3000
brand:23,garden,,true,
Supported types are string (the default, no suffix), int (stored as int64),
float (float64), and bool. An empty cell omits that field for the row,
so a rule can supply its own default (row brand:23 above has no seats or
budget). A missing id column, a duplicate id, a wrong column count, or a value
that will not coerce to its declared type is an APERTURE_CONFIG_INVALID error; a
malformed id passes through as the identity package’s APERTURE_IDENTITY_INVALID.
Loading and the read-only contract
The file is read once, lazily, on the first Fetch/List/Query and held
in memory. New(path) never fails at construction — a bad file surfaces on first
use (the file may not exist yet at wiring time). FromReader(r) builds an
already-loaded provider from any reader (embedded data, tests). Reload
re-reads the file, building a fresh set and swapping it in atomically, so maps
already handed to and cached by the Registry stay immutable — honouring the
“metadata is read-only” contract. After a Reload, call
Registry.InvalidateType to drop the now-stale cache entries.
Query honours Filter.Pattern and Filter.Limit directly and matches
Filter.Fields by string-equality (a field absent from an object never matches).
The Registry re-enforces the pattern and limit, so honouring them in the provider
is an optimisation that also keeps Query correct when called standalone.
Like the core packages, csvprovider imports only errors, identity, and
provider plus the standard library — pure-Go and CGO-free.
Where this leads
Providers feed two consumers documented elsewhere: the object metadata a rule reads, and the object enumeration an implicit/exclusive scope performs. For the CLI that inspects registered providers, see the provisioning commands.
The RBAC domain model
Every authorization decision Aperture makes is resolved against a small,
explicit graph of entities. They all live in the model package — the single
place the domain is defined, and the package every storage backend implements
through one Storage interface. The model couples only to the leaf packages
errors/ and identity/; it holds no storage, engine, or transport concepts,
so this graph is the same whether you drive it from the library,
the CLI, Twirp, or MCP.
An account is a model entity, not a package of its own — tenancy is part
of the domain, documented alongside the rest here.
The entities
| Entity | What it is |
|---|---|
| ObjectType | A protected resource type (e.g. document) with a declared, closed set of action verbs. |
| Permission | An (action, scope-strategy) pair bound to one object type; optionally Delegatable. |
| Principal | A user or machine, addressable by the identity scheme; carries its assigned RoleIDs. |
| Role | A named bundle of permissions; a principal is assigned roles, and a role may also be a grant subject. |
| Group | A collection of principals that can itself hold grants (be a grant subject). |
| Account | A first-class tenancy boundary: the unit a grant is stamped to and the context a decision is scoped to. |
| Membership | The edge linking a global principal to an account it belongs to. |
| Grant | Binds a subject to a permission, scoped to an object pattern and an effect, stamped to an account. |
| Rule | A named, persisted rule AST (see Rules) a scope strategy can reference. |
| Template | A named, versioned bundle of parameterized grants for fast, consistent provisioning. |
How they relate
erDiagram
ObjectType ||--o{ Permission : "declares verbs for"
Permission }o--|| ScopeStrategy : "references"
Role }o--o{ Permission : "bundles"
Principal }o--o{ Role : "assigned"
Group }o--o{ Principal : "contains"
Account ||--o{ Membership : "has"
Principal ||--o{ Membership : "in"
Account ||--o{ Grant : "stamps"
Grant }o--|| Permission : "on"
Grant }o--|| Subject : "for"
Subject }o--|| Principal : "or"
Subject }o--|| Role : "or"
Subject }o--|| Group : "binds"
A Subject is not a stored row of its own — it is a {Kind, ID} value
carried on the grant, where Kind is one of principal, role, or group.
At decision time the engine expands the request’s principal into a subject
set — the principal itself, the roles on Principal.RoleIDs, and the groups
that list the principal — and resolves grants against that whole set.
Object types and typed actions
An ObjectType names an identity-segment type and declares a closed verb
set in Actions. The verb set is authoritative: a Permission may only name
an action the object type declares. Declaring a permission against an
undeclared verb is rejected with APERTURE_ACTION_UNDECLARED. This
typed-action validation means a typo in an action never silently becomes an
unmatchable permission — it fails at write time.
{ "name": "document", "actions": ["read", "write", "delete", "aperture.delegate"] }
An object type opts a resource into delegation or
impersonation by declaring their reserved verbs
(aperture.delegate, aperture.impersonate.augment,
aperture.impersonate.become) and a permission on each.
Permissions
A Permission is an (action, scope-strategy) pair bound to one object type.
The ScopeStrategy field is an opaque reference resolved by the scope
layer — it decides which objects within a grant’s pattern the permission
reaches. The Delegatable flag is an opt-in, fail-closed gate: a
permission cannot be handed to another principal via bestow
until it is explicitly flagged, so the right to delegate never leaks to
permissions whose authors did not intend it.
Grants: subject × permission × object × effect × account
The Grant is the binding that actually confers (or withholds) access. It is
the load-bearing entity, so its shape is worth reading closely:
- Subject — what the grant applies to: a principal, role, or group.
- PermissionID — the granted permission (which fixes the action and scope strategy).
- Object — an identity pattern in string form, e.g.
account:acme/project:atlas/**. Wildcards and explicit id-sets (brand:{1,5,23}) are first-class, so one grant can scope broadly or to a handful of ids without many grants. The engine parses it withidentity.ParsePattern. - Effect —
allowordeny. At decision time a matching deny overrides allows at equal-or-broader specificity, with a specificity tiebreak resolved by the pattern. - AccountID — mandatory. It stamps the grant to one account.
Accounts and the isolation invariant
Accounts are global entities, and a single principal can belong to more than
one (via Membership). The core tenancy rule is the (principal,
active-account) isolation invariant: a principal’s grants in one account
never apply in another, because every grant query is account-scoped. A
Membership edge says only that a principal is in scope in an account — not
what it may do there (that is grants); the engine can optionally deny a request
whose principal is not a member of the active account, as defence in depth.
The lone, deliberate exception is the account wildcard *
(model.AccountWildcard): a grant stamped to * is loaded for decisions in
every account. It is not a real account — ValidateAccount rejects * as an
account id so no row can shadow the wildcard — and only a system-tier admin can
mint one.
Because grants are account-stamped and queries account-scoped, error messages and decision output must never surface another account’s data. Keep examples single-account.
Templates: provisioning many grants at once
A Template is a named, versioned bundle of parameterized grants. It
declares typed Params (each a segment — an identity component — or a free
string) and a set of TemplateGrants whose subject ids and object patterns
reference those params with ${name} tokens. At apply time the params are
filled with concrete values, tokens are substituted, and the bundle expands
to a set of concrete Grants stamped to a target account and applied
transactionally.
- A template is identified by the
(Name, Version)pair; storing a new version keeps older ones intact, so an apply can pin a version while new provisioning uses the latest. - Expansion is all-or-nothing: a missing, unknown, or ill-typed param — or any expanded grant that fails validation — aborts with no grants written, so a partial expansion can never be applied.
- The default grant-id prefix is
<name>-v<version>, so re-applying with the same prefix upserts (idempotent provisioning) rather than duplicating.
Apply a template from the CLI; the same expansion runs behind the Twirp and library surfaces.
Rules
A Rule is a named, persisted decision AST stored verbatim as canonical JSON —
the exact serialization the rules engine marshals. The model layer
keeps it opaque (a json.RawMessage) so it stays free of an engine dependency;
it validates only that the name is non-empty and the AST is a JSON object.
Type-checking and compilation are the rules engine’s job. A scope
strategy names a rule to make membership rule-driven.
Validation and persistence
Every entity has a Validate* function that runs before persistence — non-empty
ids, valid enum values, parseable identities and patterns, typed-action checks.
Structural failures are APERTURE_INVALID_INPUT; the specialized codes
(APERTURE_ACTION_UNDECLARED, APERTURE_TEMPLATE_INVALID,
APERTURE_RULE_INVALID, …) name the specific rule that was broken. The
Storage interface is the single seam behind which the in-memory and SQLite
backends live; validation guarantees a malformed entity never reaches the
decision hot path.
Where this leads
- Mutate these entities from the shell: CLI mutations.
- Grant a subset of your authority to someone else: Delegation.
- Borrow another principal’s authority: Impersonation.
- Turn a grant’s pattern into a concrete object set: Scopes.
- Resolve a request into a verdict: the Decision API.
Delegation (“bestow”)
Delegation lets a principal hand a slice of the authority it already holds to
another principal — without privilege escalation and without crossing an account
boundary. Aperture calls the grant-forward operation bestow and its inverse
revoke. The code lives in the delegation package; drive it from the CLI
with bestow / revoke.
The defining property: a bestowed grant is an ordinary account-scoped grant. There is no special storage and no special decision path — once bestowed, the engine treats it exactly like any other grant, and it vanishes outside its account like any other grant, because every grant query is account-scoped.
Delegation is itself a permission
There is no magic “admin can delegate” flag. The right to delegate is a normal
grant on a normal permission whose action is the reserved verb
aperture.delegate (delegation.DelegateAction). A principal “may delegate”
within an account when its effective grant set holds an allow grant on that
action whose object pattern covers the object being bestowed. So the right
to delegate is scoped exactly like every other permission — a delegate grant
over account:acme/project:atlas/** authorizes bestowing only within that
subtree.
An object type opts a resource into delegation by declaring the
aperture.delegate verb and defining a permission on it.
The bestow rule
Bestow is conjunctive and fail-closed. A delegator may bestow a grant G
only when all four conditions hold, checked against the delegator’s
(delegator, account) effective grant set:
flowchart TD
B["Bestow(delegator, G)"] --> M{"delegator is a member<br/>of G's account?"}
M -->|no| D1["DENIED: cross_account"]
M -->|yes| Del{"may-delegate right<br/>covers G's object?"}
Del -->|no| D2["DENIED: no_delegate_right"]
Del -->|yes| Flag{"G's permission<br/>is Delegatable?"}
Flag -->|no| D3["NOT_DELEGATABLE"]
Flag -->|yes| Sub{"delegator holds an allow<br/>with G's action + scope strategy<br/>whose object ⊇ G's object?"}
Sub -->|no| D4["DENIED: not_subset"]
Sub -->|yes| OK["grant written<br/>as an ordinary grant"]
- Account membership — the delegator is a member of
G’s account. A bestow stamped to any other account is rejected up front (a cross-account leakage guard, and defence in depth over the already account-scoped grant query). - May-delegate — the delegator holds an effective allow grant on
aperture.delegatewhose object pattern coversG’s object. - Delegatable —
G’s underlying permission is flaggedDelegatable. This is the model’s opt-in gate; a permission is non-delegatable until explicitly flagged. - Subset — the delegator holds an effective allow grant with the same
action and scope strategy as
G’s permission, whose object pattern coversG’s object. In other words,Gmust be a subset of the delegator’s own authority — never broader.
Only allow grants may be bestowed; a delegated deny is out of scope. Any
failure returns an APERTURE_DELEGATION_* coded error naming the first broken
condition (APERTURE_DELEGATION_DENIED with a reason, or
APERTURE_DELEGATION_NOT_DELEGATABLE) and writes nothing.
“Covers” is pattern containment
“⊇” throughout is identity.Contains: the target’s object pattern must be
equal-or-more-specific than — that is, contained within — the delegator’s. A
more-specific pattern under the delegator’s authority is a subset; a
broader-or-disjoint one is not. The containment test is conservative (sound,
possibly incomplete): when authority cannot be proven, the bestow is denied. A
delegator holding a deny grant confers nothing to hand on — only allow grants
are considered when computing what the delegator may bestow.
Revoke
Revoke is the inverse mutation and is gated by the same authority check
Bestow applies. A delegator may revoke only a grant it could itself bestow
now — so revocation can never be used to reach across accounts or beyond the
delegator’s own scope. Revoking an unknown grant is APERTURE_NOT_FOUND.
Worked example
Alice holds two allow grants in account acme: a delegate right and a read
right, both over account:acme/project:atlas/**, on a Delegatable
read permission. She bestows a narrower read grant on Bob:
export APERTURE_PRINCIPAL=alice
bin/aperture bestow --delegator alice --json '{
"id": "g-bob-read-42",
"accountId": "acme",
"subject": {"kind": "principal", "id": "bob"},
"permissionId": "perm-doc-read",
"object": "account:acme/project:atlas/document:42",
"effect": "allow"
}'
This passes all four checks: Alice is a member of acme; her delegate right
covers document:42; perm-doc-read is delegatable; and her own read grant
over .../atlas/** contains the narrower .../atlas/document:42. Had Bob’s
object been account:acme/project:atlas/** (equal to or broader than Alice’s),
the subset check would deny it.
Related
- The RBAC model — grants, permissions, the
Delegatableflag. - Identity patterns — how containment and specificity are decided.
- CLI mutations — the
bestow/revokecommands. - The service facade — how surfaces reach the gated delegation service.
Impersonation
Impersonation lets a privileged operator borrow another principal’s authority
for a decision — to reproduce what a user sees, or to act on their behalf — under
hard guardrails and a strict time-box. This chapter is the conceptual half:
what the two modes mean, how a session is gated, and how it expires. For the
engine API that consumes a session (CheckAs / EnumerateAs / ExplainAs and
the ImpersonationContext shape), see the library reference.
The feature has two halves. The impersonation package starts and gates a
session; the engine’s *As entry points consume it. A session never mutates
stored grants — it only steers which subject set a decision resolves over.
Two modes: augment vs become
flowchart LR
subgraph augment["AUGMENT"]
A1["operator subjects"] --> AU["∪"]
A2["target subjects"] --> AU
AU --> AR["decision resolves<br/>over the union"]
end
subgraph become["BECOME"]
B1["target subjects<br/>alone"] --> BR["decision resolves<br/>as if the target asked"]
end
- Augment adds the target’s effective permissions to the operator’s own. The operator keeps acting under its own identity, and the decision resolves over the union of both subject sets. Use it to “see what they can see” while retaining your own authority.
- Become fully assumes the target’s identity: the decision resolves over the target’s subject set alone, as if the target had asked. The operator’s own grants do not apply.
Become is strictly stronger than augment, and that ordering drives the gating rule below.
Impersonation is a permission — and become needs the stronger right
Like delegation, impersonation is gated by ordinary grants on reserved action verbs. Two verbs gate the two modes:
| Mode | Reserved verb | Constant |
|---|---|---|
| Augment | aperture.impersonate.augment | impersonation.AugmentAction |
| Become | aperture.impersonate.become | impersonation.BecomeAction |
An operator “may impersonate” a target when its effective allow grant set
holds the mode’s right whose object pattern covers the target principal’s
identity (identity.Contains). The strictly-stronger rule:
- Become requires an allow on
aperture.impersonate.becomecovering the target. - Augment requires an allow on
aperture.impersonate.augmentoraperture.impersonate.becomecovering the target — so holding the become right implies the augment right (a become-holder can do either), but the augment right alone can never become a target.
An object type opts a principal type into being impersonable by declaring these verbs and defining a permission on each.
Starting a session: the guardrails
Start(operator, target, account, mode) gates and issues a session. The
guardrails are conjunctive and fail closed — every one must hold, checked
against the operator’s (operator, account) effective grants:
- Account boundary — both the operator and the target are members of the active account. A session spanning accounts is refused; there is no cross-account impersonation. (The engine independently re-checks this on every decision, so a forged context cannot bypass it.)
- Right held — an effective allow grant on the mode’s action (or, for augment, the stronger become action) whose object pattern covers the target’s identity.
A failure returns APERTURE_IMPERSONATION_DENIED naming the failed guard in the
error context (operator_not_member, cross_account, no_become_right,
no_augment_right). Empty inputs or an unknown mode are APERTURE_INVALID_INPUT.
Expiry: a hard, short time-box
Impersonation is a privileged, transient act, so every session carries an
expiry. Start stamps ExpiresAt = now + ttl, where ttl defaults to
DefaultTTL — 15 minutes — and can be overridden per service with
WithTTL (a non-positive ttl is ignored, so a session is always bounded). The
clock is injected, so tests never touch the wall clock.
The ExpiresAt instant travels with the session into the engine, so the
engine enforces the time-box itself:
- A session presented after its expiry confers no elevation. Instead of the target’s authority, the decision fails closed to the operator’s own authority — an expired become never resolves as the target. Elevation never outlives its time-box.
- A surface that would rather reject an expired session up front — instead of
silently resolving with no elevation — calls
Session.Live(now), which returnsAPERTURE_IMPERSONATION_EXPIREDonce the session is past its expiry.
Every resulting decision records both the real operator and the effective subject for the audit layer — impersonation is never silent.
Starting one from the CLI
bin/aperture impersonate --operator root --target alice \
--account acme --mode augment
This prints the session (operator, target, account, mode, and expiry) as JSON.
The command is guarded exactly as above: an operator with no right covering
alice is denied with APERTURE_IMPERSONATION_DENIED. See
CLI mutations.
Related
- Library: Impersonation — the
*Asengine API and theImpersonationContextdecorator this conceptual page complements. - The RBAC model — subjects, grants, and reserved action verbs.
- Delegation — the sibling “grant a subset of your authority” feature, gated the same way.
- The service facade — how surfaces reach the gated impersonation service.
Filtering entity lists
The filter package applies server-side field predicates to a list of
entity JSON bodies before they are returned to a client. It is the business
logic behind the admin UI’s data-grid filters: the client sends a Spec (a set
of predicates), the server evaluates it against each entity, and only the
matches cross the wire. Rows filtered out are never sent — the filter runs where
the data lives, not in the browser.
Why it is dynamic, not per-type
Aperture’s entities are heterogeneous — accounts, principals, roles, grants, and
so on. Rather than a typed filter per entity, filter addresses fields by their
JSON key and evaluates over the decoded map[string]any. One predicate
engine works across every entity list. Two consequences fall out of this:
- Text comparisons are case-insensitive. Both the field value and the predicate value are lower-cased before comparing.
- Array fields match if any element satisfies the predicate. A principal’s
rolesarray matchesroles contains adminwhen any single role does. JSON numbers and booleans are rendered to strings for the comparison (an integer prints without a trailing.0).
Predicates and the spec
A Predicate is one field test — a Field (JSON key), an Op, and a Value:
type Predicate struct {
Field string
Op string
Value string
}
type Spec struct {
Predicates []Predicate
MatchAny bool // OR when true; AND (the default) when false
}
A Spec combines its predicates with AND by default, or OR when
MatchAny is set. Spec.Empty() reports whether the spec would constrain
nothing, so a caller can skip the work entirely.
The operators
The operator set is deliberately small — “core”:
| Op | Constant | Matches when the field… |
|---|---|---|
eq | OpEq | equals the value (case-insensitive) |
contains | OpContains | contains the value as a substring (case-insensitive) |
starts | OpStarts | starts with the value as a prefix (case-insensitive) |
empty | OpEmpty | is empty or absent — the value is ignored |
For empty, a field counts as empty when it is absent, null, an empty string,
or an empty array. For the three value operators, a predicate with an empty
Value is treated as unusable and ignored. An unknown operator also
makes its predicate a no-op — it is simply skipped, never an error.
Evaluation and the fail-open rule
Apply(entities, spec) returns the subset of entities (each a JSON object
body) that satisfy the spec:
- An empty spec — no usable predicates — returns the input unchanged.
- Usable predicates are collected; then each entity is decoded and tested,
combining predicate results with AND or OR per
MatchAny. - An entity whose JSON does not decode to an object is kept. This is the deliberate fail-open rule: a filter must never silently hide a row it could not evaluate. (Contrast the decision engine, which fails closed; a display filter is not an authorization boundary, so it errs toward showing data rather than hiding it.)
matches := filter.Apply(bodies, filter.Spec{
Predicates: []filter.Predicate{
{Field: "kind", Op: filter.OpEq, Value: "user"},
{Field: "roles", Op: filter.OpContains, Value: "analyst"},
},
// MatchAny false → both must hold (AND).
})
// matches holds only the user principals that carry an "analyst" role.
Scope note
filter narrows a list the caller is already authorized to see — it is a
display convenience, not an access-control gate. The authorization decision that
determines which entities a caller may list at all is made upstream by the
decision engine; a grant’s own object membership is
decided by scope strategies. Because the lists a filter runs over
are account-scoped upstream, filtering never widens visibility across the
account isolation boundary.
Related
- The RBAC model — the entities whose JSON bodies are filtered.
- Scopes & scope strategies — the authorization-side “which objects” question, distinct from display filtering.
- Admin UI shell — the data-grid this filtering backs.
Authentication
The auth package turns an incoming external credential into a known
Aperture principal. Authentication is always external: Aperture consumes
credentials, it never issues them. There is no login, signup, or
credential-issuance surface — a caller arrives already holding a bearer token,
and auth decides who that token names.
The Authenticator seam
Every adapter implements one small interface:
type Authenticator interface {
Authenticate(ctx context.Context, bearer string) (principalID string, claims Claims, err error)
}
The input is the raw bearer token — the value after Bearer in the
Authorization header. The HTTP middleware in internal/server extracts it from
the request and calls Authenticate; on success it attaches a Principal to the
request context. Handlers (and the decision surface) recover the caller with
PrincipalFromContext.
type Principal struct {
ID string // the resolved Aperture principal id
Claims Claims // the verified assertions behind it (empty for dev)
}
Claims is always a generic map[string]any, keyed by claim name, so downstream
code reads any claim uniformly regardless of which adapter produced it.
Every adapter fails closed. A missing, malformed, or unverifiable credential is an error, never a silently-empty principal:
- an empty or unresolvable credential →
APERTURE_UNAUTHENTICATED - a presented credential that fails verification →
APERTURE_INVALID_TOKEN
An anonymous request (no credential at all) surfaces as ok == false from
PrincipalFromContext; callers that require a principal treat that as
APERTURE_UNAUTHENTICATED.
The claim → principal mapping
The two verifying adapters (OIDC and Parsec) resolve the principal id through the
same configurable mapping: PrincipalClaim names which verified claim — sub,
email, or a custom claim — becomes the Aperture principal id. Which claim is the
principal is configuration, not code. It defaults to sub when unset.
A verified token that does not carry the configured claim (or carries it empty, or
non-string) is APERTURE_UNAUTHENTICATED — the token verified, but it does not
name a principal Aperture can use.
The dev adapter is the one exception by construction: its bearer is the principal, so there is no claim to map.
The three adapters
dev / static (NewDev)
Trusts any non-empty bearer as the principal id — Authorization: Bearer alice
resolves to principal alice. It performs no verification and returns nil claims.
This is the adapter that makes Aperture runnable with no external IdP: fixtures,
demos, and CI. It is the default when no auth mode is configured.
It is a development / single-tenant trust shortcut, not a security boundary. Never select it for a deployment facing untrusted callers. Even so, it still fails closed on an empty bearer.
oidc / JWT (NewOIDC)
Verifies a bearer JWT against an OIDC provider’s published keys using the pure-Go
github.com/coreos/go-oidc verifier: signature, issuer (iss must equal
Issuer), audience (aud must contain Audience), and expiry. Signing keys are
established up front — by JWKS discovery against Issuer, or from an explicit
JWKSURL when the provider has no discovery document — so per-request
verification is offline against the cached key set.
Issuer and Audience are both required: an empty audience would accept tokens
minted for any client. Discovery performs network I/O, so Build/NewOIDC take a
ctx. A missing issuer or audience is APERTURE_CONFIG_INVALID; a discovery
failure at startup is APERTURE_BOOT.
parsec (NewParsec)
Verifies a token minted by the in-house github.com/frankbardon/parsec token
broker against the broker’s signing keyring. This is Orbit’s realtime
token-broker pattern: the broker issues a client a short-lived access token, and
Aperture loads the same keyring to verify that token and learn who the caller is.
The keyring is the integration seam — the broker and Aperture must share it,
because a parsec key carries a per-key id (kid) the verifier matches the token
against. Point Aperture at the broker’s persisted keyring exactly one of two ways
(KeyringPath wins):
KeyringPath— the broker’skeyring.jsondirectly.StateDir— the broker’s state directory, inside whichkeyring.jsonis resolved (the Orbitserve.goshape,StateDir = <DataDir>/parsec).
The verifier follows the ring by reference, so a broker key rotation that rewrites
the ring takes effect without restarting Aperture. By default the adapter
accepts the parsec access token type — the connection token a caller presents —
and maps the sub claim (which parsec stamps with the user id) to the principal.
A missing keyring source is APERTURE_CONFIG_INVALID; a load failure is
APERTURE_BOOT; a verification failure (bad signature, wrong type, expired) is
APERTURE_INVALID_TOKEN.
Parsec is an in-house credential source. This page documents only what the
auth/parsec.goadapter exposes — the keyring-verification seam. The broker’s own token-minting protocol lives in theparsecmodule, not here.
Selecting an adapter: config
Which adapter is built is chosen by configuration through auth.Config, whose
zero value selects the dev adapter — the documented default that makes Aperture
runnable with no IdP. Config.Build(ctx) returns the matching Authenticator; an
unrecognised mode is APERTURE_CONFIG_INVALID.
ConfigFromEnv reads the configuration from APERTURE_* environment variables:
| Env var | Config field | Applies to | Notes |
|---|---|---|---|
APERTURE_AUTH_MODE | Mode | all | dev | oidc | parsec; empty ⇒ dev |
APERTURE_AUTH_PRINCIPAL_CLAIM | PrincipalClaim | oidc, parsec | empty ⇒ sub |
APERTURE_OIDC_ISSUER | OIDCIssuer | oidc | required (issuer URL + discovery root) |
APERTURE_OIDC_AUDIENCE | OIDCAudience | oidc | required (Aperture’s client id at the IdP) |
APERTURE_OIDC_JWKS_URL | OIDCJWKSURL | oidc | optional; set to skip discovery |
APERTURE_PARSEC_KEYRING | ParsecKeyringPath | parsec | path to keyring.json (wins over state dir) |
APERTURE_PARSEC_STATE_DIR | ParsecStateDir | parsec | broker state dir; keyring.json resolved inside |
# OIDC against an IdP with a discovery document.
export APERTURE_AUTH_MODE=oidc
export APERTURE_OIDC_ISSUER=https://accounts.example.com
export APERTURE_OIDC_AUDIENCE=aperture
export APERTURE_AUTH_PRINCIPAL_CLAIM=email
Related
- The RBAC model — the principal an authenticated credential resolves to.
- Audit trail — where an authenticated actor’s actions are recorded.
- The authz gate — how administrative authority is enforced on the resolved principal.
- Error codes —
APERTURE_UNAUTHENTICATED,APERTURE_INVALID_TOKEN,APERTURE_CONFIG_INVALID,APERTURE_BOOT.
Audit trail
Aperture keeps an append-only audit trail (FR-25). The trail is weighted toward safety-critical events without sinking the decision hot path, so it records under two disciplines depending on the event class.
Two recording disciplines
Always + synchronous. Every mutation, every impersonation event, and every delegation is recorded the moment it happens, reliably, on the calling goroutine. Mutations are not the hot path, so a synchronous, durable write is the right trade — a caller can even surface a failed mutation-audit write if it chooses.
Sampled + asynchronous. Decision checks (Check / Enumerate / Explain)
are the hot path. They are recorded only when the configured Sampler keeps them,
and the keep is handed to a background writer over a buffered channel. The
decision never blocks on the audit write: if the buffer is full the event is
dropped best-effort, so an audit backlog can never regress the decision NFR.
rec := audit.New(store,
audit.WithSampleRate(0.01), // keep ~1% of decisions
audit.WithBuffer(4096),
)
defer rec.Close() // flushes buffered decision events; call on shutdown
The Recorder owns one background writer goroutine, is safe for concurrent use,
and is drained deterministically by Close. The background writer runs detached
from any request context, so a cancelled request never aborts an in-flight audit
write.
| Method | Discipline | Notes |
|---|---|---|
Record(ctx, ev) | always, synchronous | stamps id + timestamp, writes through the sink, returns the storage error |
RecordDecision(ctx, fn) | sampled, asynchronous | invokes fn only on a keep, so an un-sampled decision pays nothing but the Sampler call; returns whether it was sampled |
Close() | — | idempotent; flushes the buffer, waits for the writer. After Close, RecordDecision is a no-op; Record still writes |
Sampling and determinism
The Sampler and the clock are injected, so tests are not flaky — inject a
deterministic sampler (e.g. keep 1-in-N via SamplerFunc) and a fixed clock
rather than relying on wall-clock time or an unseeded global rand. Production uses
WithSampleRate, a probabilistic sampler over math/rand/v2. With no sampling
option, decision audit is off (rate 0) while always-on events are still
recorded.
Construction options:
| Option | Effect |
|---|---|
WithSampleRate(r) | probabilistic decision sampler, r clamped to [0,1] (0 disables decision audit, 1 records every decision) |
WithSampler(s) | explicit Sampler (takes precedence over WithSampleRate; used in tests) |
WithBuffer(n) | async writer buffer capacity (default 1024) |
WithClock(fn) | override the timestamp clock (default time.Now) |
WithIDFunc(fn) | override the event-id generator (default: random hex) |
WithErrorHandler(fn) | observability callback for async write failures and buffer-overflow drops; never affects the decision path |
The event shape
The Recorder persists through a Sink (AppendAudit(ctx, ev)), which
model.Storage satisfies. Each entry is a model.AuditEvent — a public
contract the audit viewer reads, so its field set is additive-only:
| Field | Meaning |
|---|---|
ID, Timestamp | assigned by the recorder |
EventType | broad category the query filters on: mutation, decision, impersonation, delegation |
Action | the specific operation, e.g. PutGrant, Check, Bestow, ImpersonationStart |
Actor | the principal that really acted |
EffectiveSubject | the target whose authority was borrowed under impersonation (empty otherwise) |
ImpersonationMode | augment or become under an impersonation session (empty otherwise) |
Account | the active account the event was scoped to |
Target | the entity, object, or resource the event concerns |
Outcome | allow/deny for a decision, success/failure for a mutation/impersonation/delegation |
Reason | human-readable explanation (deciding grants, or the failure cause) |
Details | optional structured blob backends persist as JSON |
A record made under impersonation carries both the real actor and the effective subject, so an impersonated action is never mis-attributed to the target alone.
Append-only, at the storage layer
The trail is append-only by contract in model.Storage, not by convention:
AppendAudit— the only single-event write.QueryAudit(filter)— reads events matching anAuditFilter(actor, account, event type, outcome, time bounds, limit — each optional, ANDed together), returned newest-first. A zero filter returns the whole trail.PruneAudit(policy)— the only delete, and only in bulk: retention pruning by age (Before) and/or size (MaxCount), returning the count removed.
There is no update and no single-event delete, so a recorded event cannot be
silently altered. Backend failures surface as APERTURE_STORAGE.
Related
- The authz gate — the gate that guards the mutations this trail records.
- Impersonation — why events carry both a real actor and an effective subject.
- Delegation (“bestow”) — always-recorded bestow/revoke events.
- Storage — the
Storagebackend the trail is persisted through.
The authz gate
The authz package is the authorization gate the model-mutation API calls
before every mutation. It expresses Aperture’s internal administrative authority —
the right to change the system itself — and enforces the required authority on
each mutation (FR-17).
Authority is in-scheme, not a parallel system
The gate’s defining property: an admin right is just an ordinary grant. It is
an allow grant on a reserved admin action verb, aperture.admin, whose object
pattern covers a tier’s authority identity. The gate decides authority by
resolving that grant through the same engine that answers every other question
— engine.Check / engine.Explain — so an admin check is an ordinary decision:
wildcard-resolvable, auditable, and explainable, with no special-cased bypass
path.
This follows the same idiom as delegation (aperture.delegate) and impersonation
(aperture.impersonate.*): a reserved action verb plus an identity-pattern-scoped
grant.
Two tiers
| Tier | Authority anchor | Governs | Holder |
|---|---|---|---|
| System | system:schema (spelled system:* as a grant) | the global schema — object-types, permission types, roles, groups, principals, providers, templates, rules — plus tenancy (accounts) | anyone whose effective allow grants in their active account include an allow on aperture.admin covering system:* (or the all-covering **) |
| Account | account:<acct>/admin:all (spelled account:<acct>/admin:*) | grants and delegation within one account only | anyone whose effective allow grants in that account include an allow on aperture.admin covering account:<acct>/admin:* |
Account-admin authority is confined to its own account. Because an
account-tier check resolves against the target account, an account-admin of
account A is refused any mutation scoped to account B — A’s grants are not even
loaded for B, and account:A/admin:* does not cover account:B/admin:*.
System supersedes account. A system-admin may drive any account-tier mutation
in any account — including a freshly-created account that holds no admin grants of
its own yet. Authorize checks system-admin first for account-tier mutations and
only falls back to the per-account check for non-system actors (so its richer
denial context surfaces).
On the address spelling. The identity grammar accepts
**only as a standalone path segment, not inside an id component, so the in-scheme spelling of a tier’s authority is the single-component wildcard the grammar supports:system:*andaccount:<acct>/admin:*. A broader holder (account:acme/**, or the all-covering**) still resolves, so the authority is genuinely wildcard-resolvable. A principal wanting both tiers at once holds an allow onaperture.adminover**, which covers every tier anchor.
The mutation → tier policy lives in one place
Mutation is the key the mutation API passes to the gate, so the gate — not each
endpoint — owns the mutation→tier policy. A single map, mutationTier, is the
authoritative source of truth:
- System tier:
put_object_type,put_permission,put_role,put_group,put_principal,put_provider,put_template,put_rule,put_account(and theirdelete_*pairs), plusimport— applying a whole declarative state file, the most privileged mutation there is. - Account tier:
put_grant/delete_grant,bestow/revoke(delegation), andput_membership/delete_membership.
TierOf(m) reports a mutation’s tier and whether it is known. An unknown
mutation fails closed — the gate refuses an operation it has no policy for.
The API
gate := authz.NewGate(eng) // holds only the decision engine
// Enforce the tier a mutation requires. For an account-tier mutation, account
// is the target account (e.g. the grant's AccountID) and is mandatory.
err := gate.Authorize(ctx, authz.Actor{Principal: "alice", Account: "acme"},
authz.MutationPutGrant, "acme")
| Method | Returns nil when… | Otherwise |
|---|---|---|
Authorize(ctx, actor, m, account) | the actor holds the tier m requires | APERTURE_AUTHZ_DENIED; unknown mutation ⇒ APERTURE_INVALID_INPUT |
RequireSystemAdmin(ctx, actor) | the actor holds system-admin authority | APERTURE_AUTHZ_DENIED |
RequireAccountAdmin(ctx, actor, account) | the actor holds account-admin in the target account | APERTURE_AUTHZ_DENIED |
ExplainSystemAdmin(ctx, actor) | — | the engine Trace behind the system-admin decision |
ExplainAccountAdmin(ctx, actor, account) | — | the engine Trace behind the account-admin decision |
Actor is the principal id plus the active account it is operating in. The
Account is mandatory for a system-tier check (it is where the actor’s system:*
grant is resolved); for an account-tier check the target account governs
instead, so confinement holds regardless of Actor.Account.
Because every authority question resolves through the normal engine,
ExplainSystemAdmin / ExplainAccountAdmin return a full derivation Trace
whose verdict matches the corresponding Require* — the admin check is
explainable on admin identities exactly like any other decision.
Related
- The decision engine — the
Check/Explainevery authority question resolves through. - Delegation (“bestow”) — the
bestow/revokemutations this gate guards, and theaperture.delegateidiom it mirrors. - Impersonation — the
aperture.impersonate.*idiom. - Audit trail — where a gated mutation’s outcome is recorded.
- Error codes —
APERTURE_AUTHZ_DENIED,APERTURE_INVALID_INPUT.
Seed & portability
The seed package loads a declarative authorization model — a single JSON or
YAML document — into a model.Storage, and exports one back out. It is both the
human-authored on-ramp behind the aperture check / aperture serve demo and the
full round-trip state file the model portability endpoints use.
One document, both directions
A Document is a flat list of each entity kind. The field tags cover both YAML
and JSON, so either format decodes into the same shape:
accounts: [{ id: acme, name: Acme }]
memberships: [{ principal: alice, account: acme }]
object_types: [{ name: document, actions: [read, write] }]
permissions: [{ id: doc.read, object_type: document, action: read, scope_strategy: implicit }]
principals: [{ id: alice, kind: user, roles: [reader] }]
roles: [{ id: reader, name: Reader, permissions: [doc.read] }]
groups: [ ... ]
grants: [{ id: g1, account: acme, subject: { kind: principal, id: alice }, permission: doc.read, object: "account:acme/document:*", effect: allow }]
templates: [ ... ]
rules: [ ... ]
providers: [ ... ] # runtime wiring, not model state (see below)
Every field mirrors its model counterpart in declarative form. The Document
started as a minimal seed shape and was generalized to the complete model, so an
export file is a strict superset of a seed file: a seed that omits
templates/rules/providers loads unchanged, and a full export reloads through
the very same path. The field set is additive-only, so old seeds keep loading.
Rule ASTs are carried as raw JSON — exactly the rules package’s canonical
Node serialization — so the file never invents a second rule format; it is the
same shape the node editor reads and writes.
Loading (import)
// From bytes, explicit format:
err := seed.Load(ctx, store, data, seed.FormatYAML)
// From a file — format inferred from the extension (.json ⇒ JSON, else YAML):
err := seed.LoadFile(ctx, store, "model.yaml")
Parse decodes the document; Apply upserts it into the store in dependency
order: accounts, object types, permissions, principals, memberships, roles,
groups, grants, templates, then rules. Each write goes through the storage layer’s
own validation — a malformed entity surfaces the same coded error a programmatic
Put would (e.g. APERTURE_ACTION_UNDECLARED for a permission naming an
undeclared action). Rule ASTs are additionally validated against the rules
engine’s contract before storing, so an import rejects a structurally broken rule
(APERTURE_RULE_INVALID) rather than persisting one the engine could never
compile.
Applyis not transactional — a failure may leave a partial model. This is acceptable for the seed-and-demo use case. (The mutation API’s bulk endpoints useStorage.Atomicwhen all-or-nothing is required.)
The YAML path routes through JSON internally (yaml → generic → json → Document)
so the raw-JSON rule AST decodes by exactly the same rules the JSON path uses.
The committed example
seed.Example is the embedded org → project → document fixture stamped to
account acme (seed.ExampleAccount). It is what aperture check loads when no
--seed file is supplied, and it backs the end-to-end test.
Exporting
Export(ctx, store) reads the complete model back out into a Document, and
Marshal(doc, format) renders it to on-disk bytes:
doc, _ := seed.Export(ctx, store)
out, _ := seed.Marshal(doc, seed.FormatJSON)
Export captures every source-of-truth entity: accounts, memberships, object types, permissions, principals, roles, groups, grants, templates, and rule ASTs. Two properties make a round-trip trustworthy:
- Byte-stable. Every slice is emitted in a stable order (sorted by id, name, or natural key) and each rule AST is re-serialized to the rules package’s canonical form, so a re-export of an unchanged model is byte-identical and human-diffable.
- Wildcard edges are preserved. Memberships and grants stamped to the wildcard
account
*(the cross-account super-admin reach) are not among the real accounts, soExportqueries*explicitly — omitting it would silently drop a super-admin’s reach on export/import.
What is not in the file
Two things are deliberately excluded from the model state file:
- Live host domain-object metadata — that is the provider
cache: derived, disposable, never source of truth. Because
Exportreads storage back, and a provider produces no model rows, it is never reproduced. - Provider wiring — the
providers:section is runtime wiring, not model state.Applynever writes it to storage; insteadDocument.BuildRegistry(baseDir)turns it into a live*provider.Registry. The seed file is the source of truth for provider wiring, exactly as auth config is. A declared provider names anobject_type, akind(currently onlycsv), apath(resolved relative to the seed file), and optional cachettl/max_size. A malformed entry isAPERTURE_CONFIG_INVALID/APERTURE_PROVIDER_INVALID.
Related
- The RBAC model — the entities the document mirrors.
- Rules engine — the canonical AST a rule’s
astfield carries. - Providers — the registry
providers:wiring builds, and the cache that is never exported. - Storage — the
StoragebackendApplywrites through andExportreads back. - Portability CLI — the command surface over import/export.
Error taxonomy
Every failure surfaced by the Aperture library is an APERTURE_* coded error.
Codes exist so the CLI, Twirp/HTTP, and MCP surfaces can translate a failure to a
transport-appropriate status without string-matching human-readable messages —
the code is the stable contract, the message is not.
This page is the concept. The exhaustive, generated list of every code with its message and fixups is the Error Codes reference, produced from the
Registryinerrors/codes.go.
The coded-error type
CodedError is the canonical error type:
type CodedError struct {
Code Code // the failure class, e.g. APERTURE_NOT_FOUND
Msg string // human-readable summary
Context map[string]any // structured detail
Inner error // the wrapped cause
}
It implements error and Unwrap, so errors.Is / errors.As inspect it
normally.
Constructing and recovering codes
Construct a coded error with one of the wrappers; recover the code with CodeOf:
| Constructor | Use |
|---|---|
New(code, msg) | a fresh error; empty msg falls back to the code’s canonical Registry message |
Newf(code, format, …) | New with a formatted message |
WithContext(code, msg, ctx) | a fresh error carrying a structured Context map |
Wrap(code, msg, inner) | attach a code + summary to an existing error |
Wrapf(code, inner, format, …) | Wrap with a formatted message |
CodeOf(err) Code | the APERTURE_* code for an error, or "" when none is attached |
if _, err := store.GetGrant(ctx, id); err != nil {
if errors.CodeOf(err) == errors.APERTURE_NOT_FOUND {
// ... handle the absent grant
}
}
Wrapping rules
Two rules govern how codes propagate:
- Never re-stamp. An error that already carries an
APERTURE_*code passes through verbatim.CodeOfrecovers the existing code; the wrappers do not overwrite it. This lets a lower layer set the precise code (e.g. a provider returningAPERTURE_NOT_FOUNDfor an absent object) and have it survive unchanged up through the callers. - Never leak cross-account data. A code and its message must not carry another
account’s entity ids, names, or contents. Cross-account isolation is a hard
invariant of the engine; an error message is not an exception to it. Put
narrowing detail in
Context(structured, controllable) rather than interpolating tenant data into free-text messages.
Across package boundaries, never return a bare errors.New / fmt.Errorf —
wrap it in an APERTURE_* coded error so every surface can translate it.
The Registry and its gates
Every code has one entry in Registry (the Orbit pattern), a
map[Code]Metadata:
- a canonical
Message(the fallback summary and the reference-table text), and - either at least one
Fixup(an operator-actionable hint) orFixupNotApplicable = truewhen no fixup is meaningful.
Codes are SCREAMING_SNAKE, APERTURE_-prefixed, and listed in the AllCodes
slice. This structure is enforced by non-skippable CI gates:
| Gate | Enforces |
|---|---|
TestCodesHaveFixups | every code has a Registry entry with a Message and a Fixup (or FixupNotApplicable) |
TestRegistryHasNoOrphans | Registry contains nothing absent from AllCodes |
TestCodesAreScreamingSnakeNamespaced | every code is SCREAMING_SNAKE and APERTURE_-prefixed |
Adding a code therefore means: append it to AllCodes, and add a Registry entry
with a message and fixups — or the build fails.
Related
- Error Codes reference — the generated table of every code, message, and fixup.
- The decision engine — the surface whose failures these codes classify.
- Authentication, The authz gate, Storage — packages that raise the auth, authorization, and storage codes.
Storage
model.Storage is Aperture’s persistence boundary — the single seam every
backend implements. Two backends ship, both behind the one interface:
storage/memory— a map-backed, concurrency-safe store for tests, seeding, and any deployment that does not need durability.storage/sqlite— the durable reference backend onmodernc.org/sqlite, a pure-Go driver, soCGO_ENABLED=0holds end to end. It uses a hand-written, embedded schema (schema.sql) — no ORM, no sqlc, no migration tool.
The interface is deliberately free of any backend-specific concept, so a future
Postgres backend slots in unchanged. Both backends enforce the same validation
and typed-action rules and pass the shared conformance suite
(storage/storagetest), so behavior is identical across them.
The interface contract
type Storage interface {
Setup(ctx context.Context) error // create/migrate schema; idempotent; call once
Close() error
// Account, Membership, ObjectType, Permission, Principal, Role, Group, Grant,
// Template, Rule — each with Put/Get/List/Delete as applicable.
// ... plus decision-engine queries, Atomic, and the audit trail.
}
Shape and error conventions, uniform across every entity:
| Operation | Contract |
|---|---|
Put* | upsert keyed on the entity’s id (object types on name): create when absent, replace when present; validates its argument |
Get* | returns APERTURE_NOT_FOUND when the id is unknown |
List* | returns every entity of the kind (grants are listed per account) |
Delete* | returns APERTURE_NOT_FOUND when the id is unknown |
| any backend failure | surfaces as APERTURE_STORAGE |
PutPermission additionally enforces typed-action validation against the
referenced object type (APERTURE_ACTION_UNDECLARED), and PutGrant validates
that Object parses as an identity pattern and that AccountID is present.
All methods are safe for concurrent use by multiple goroutines. The in-memory
backend guards its maps with a single RWMutex; the SQLite backend caps its
connection pool at one connection, since SQLite is a single-writer engine and one
connection avoids “database is locked” contention.
Account stamping is enforced in the queries
Cross-account isolation is a data-layer guarantee, not just a service-layer
convention. Every grant carries an AccountID, and the account-scoped queries —
ListGrants(account) and the engine’s hot-path GrantsForSubjects(account, subjects) — mean a grant stamped to one account can never surface in another.
Decision-engine queries
Two methods exist specifically for the decision hot path:
GrantsForSubjects(ctx, account, subjects)— the engine expands a principal into its subject set (the principal, its roles, its groups) and asks for exactly the account-scoped grants bound to that set.GroupsForPrincipal(ctx, principal)— the group half of a principal’s subject set.
IsMember(ctx, principal, account) is a tight existence check the engine uses to
enforce membership without materializing the full membership list.
Transactions
Atomic(ctx, fn) runs fn inside a transaction against a tx-scoped Storage,
committing when fn returns nil and rolling the whole batch back on any error.
Both backends give real atomicity — SQLite via BEGIN/COMMIT/ROLLBACK, the
in-memory backend via a staged snapshot committed only on success. It is the
primitive the bulk grant/revoke endpoints and template apply build on. fn must
use the tx handed to it (not the outer Storage); a nested Atomic flattens
into the current transaction, so an outer rollback still covers everything.
err := store.Atomic(ctx, func(tx model.Storage) error {
if err := tx.PutGrant(ctx, g1); err != nil {
return err // rolls back g1 and anything else in the batch
}
return tx.PutGrant(ctx, g2)
})
The audit trail
The same Storage seam carries the append-only audit trail:
AppendAudit (the only single-event write), QueryAudit (newest-first, filtered),
and PruneAudit (bulk retention delete). There is no update and no single-event
delete, so a recorded event cannot be silently altered.
Construction
mem := memory.New() // in-memory
db, _ := sqlite.Open("aperture.db") // durable file
// db, _ := sqlite.OpenMemory() // ephemeral SQLite (tests)
_ = db.Setup(ctx) // once, before any other call
Related
- The decision engine — the hot-path reader of
GrantsForSubjects/GroupsForPrincipal. - The RBAC model — the entities
Storagepersists. - Seed & portability — the declarative loader/exporter over
Storage. - Audit trail — the append-only trail this interface carries.
- Error codes —
APERTURE_STORAGE,APERTURE_NOT_FOUND,APERTURE_ACTION_UNDECLARED.
Error Codes
Every failure surfaced by Aperture is an APERTURE_* coded error. Each code carries a canonical message and, where operator action is meaningful, one or more fixup hints. This page is generated from the error Registry in errors/codes.go.
| Code | Message | Fixups |
|---|---|---|
APERTURE_ACTION_UNDECLARED | action is not declared on the object type | Add the action verb to the object type’s declared action set, or grant a verb the type already declares. List the object type’s actions to see the validated verb set. |
APERTURE_AUTHZ_DENIED | the actor lacks the admin authority tier required for this mutation | Schema mutations (permission types, roles, object-types, providers, templates, rules) require system-admin authority: an allow grant on the admin action whose object covers system:. Grant and delegation mutations require account-admin authority in the TARGET account: an allow grant on the admin action whose object covers account:<acct>/admin:. Account-admin authority is confined to its own account; obtain authority in the account the mutation targets, or hold a broader (e.g. **) grant. |
APERTURE_BOOT | aperture failed to start | Check the APERTURE_* environment variables and any –config file. Confirm the storage backend (memory or sqlite) is reachable. |
APERTURE_CONFIG_INVALID | configuration is invalid | Validate the YAML config and APERTURE_* env vars against the docs. |
APERTURE_DELEGATION_DENIED | the delegator may not bestow this grant | Bestow only grants that are a subset of your own effective allow grants in the account (same action and scope strategy, an equal-or-more-specific object pattern). Confirm you hold a ‘may delegate’ right whose object pattern covers the grant’s object. Bestow grants only within an account you are a member of; cross-account bestowal is rejected. |
APERTURE_DELEGATION_NOT_DELEGATABLE | the permission is not flagged delegatable | Set Delegatable on the permission definition to allow it to be bestowed. |
APERTURE_IDENTITY_INVALID | object identity is malformed | Use type:id segments joined by ‘/’, e.g. account:acme/project:atlas/document:42. Ensure no segment is empty and every segment carries a ‘:’ with a non-empty type and id. Remove illegal characters; types and ids allow letters, digits, and -._~@+ only (‘*’ marks a wildcard in patterns). |
APERTURE_IMPERSONATION_DENIED | the operator may not impersonate this target | Impersonate only within an account both the operator and the target are members of; cross-account impersonation is refused. Confirm the operator holds an impersonation right (augment or become) whose object pattern covers the target’s identity. Become mode requires the stronger become right; an augment right alone cannot become a target. |
APERTURE_IMPERSONATION_EXPIRED | the impersonation session has expired | Start a fresh impersonation session; sessions are time-boxed and expire automatically. |
APERTURE_INVALID_INPUT | input failed validation | Re-check the request shape against the command or API contract. |
APERTURE_INVALID_TOKEN | the presented bearer credential failed verification | Confirm the token is a well-formed JWT signed by the configured issuer’s keys. Check the token issuer and audience match APERTURE_OIDC_ISSUER and APERTURE_OIDC_AUDIENCE, and that it has not expired. For a parsec adapter, confirm the token was minted by the broker sharing the configured keyring/secret. |
APERTURE_NOT_FOUND | the referenced entity was not found | Confirm the identifier exists in the current account scope. |
APERTURE_PROVIDER_FETCH | object provider returned an error | Inspect the wrapped cause for the underlying provider failure. Return APERTURE_NOT_FOUND from the provider for an object that does not exist. |
APERTURE_PROVIDER_INVALID | object provider registration is invalid | Register a non-nil provider under a non-empty object-type key. Register each object type at most once; check for a duplicate registration. |
APERTURE_PROVIDER_UNREGISTERED | no object provider is registered for the object type | Register an ObjectProvider for the object type before fetching its metadata. Confirm the object identity’s terminal segment type matches a registered provider key. |
APERTURE_RULE_EVAL | rule evaluation failed | Inspect the wrapped cause for the underlying evaluation failure. Ensure the rule expression yields a boolean for the supplied context. |
APERTURE_RULE_INVALID | rule AST is malformed | Give each logical node the right child count: and/or take two or more, not takes exactly one. Give every comparison a left and right operand, and every literal a scalar value. Write variable references as dotted identifier paths, e.g. object.classification. |
APERTURE_RULE_NOT_FOUND | the referenced rule was not found | Confirm the rule reference exists in the configured rule source. |
APERTURE_RULE_TYPE_ERROR | rule failed expression type checking | Compare compatible types and make the rule evaluate to a boolean. Call only functions registered with the rules engine. |
APERTURE_RULE_UNKNOWN_VARIABLE | rule references an unknown variable | Reference variables under a known context root: object, principal, account, or action. Check for a typo in the variable’s root segment. |
APERTURE_SCOPE_INVALID | scope strategy reference is malformed | Use ‘strategy’ or ‘strategy;param=value’ form, e.g. inclusive;ids=account:acme/document:42. Give an inclusive/exclusive strategy an ‘ids’ list or a ‘rule’ reference; implicit takes no configuration. |
APERTURE_SCOPE_LISTER_UNCONFIGURED | scope enumeration requires an object lister that is not configured | — not applicable |
APERTURE_SCOPE_RULE_UNCONFIGURED | scope rule path requires a rule evaluator that is not configured | — not applicable |
APERTURE_SCOPE_UNKNOWN_STRATEGY | scope strategy is not registered | Use a built-in strategy (literal, implicit, inclusive, exclusive) or register the custom key with the scope registry. |
APERTURE_STORAGE | the storage backend returned an error | Inspect the wrapped cause for the underlying storage failure. |
APERTURE_TEMPLATE_INVALID | the provisioning template is malformed | Give the template a non-empty name, a version of at least 1, and at least one grant. Declare every parameter a grant references; write references as ${name} with a declared parameter. Give each template grant a valid subject, a permission id, an allow/deny effect, and a non-empty object pattern. |
APERTURE_TEMPLATE_PARAM | the template apply supplied invalid parameters | Supply a value for every parameter the template declares, and no parameters it does not. A segment-typed parameter value must be a legal identity component: letters, digits, and -._~@+ only. |
APERTURE_UNAUTHENTICATED | the request could not be resolved to a known principal | Present a credential: send an Authorization: Bearer <token> header. With the dev/static authenticator the bearer IS the principal id; send a non-empty value. Confirm the verified token carries the configured principal claim (APERTURE_AUTH_PRINCIPAL_CLAIM, default ‘sub’). |
APERTURE_UNIMPLEMENTED | this surface is not yet implemented | — not applicable |
Command-Line Reference
Audience: operators and integrators driving Aperture from a shell.
aperture — Fine-grained access control engine. This page is generated from the urfave/cli command tree in internal/cli (cli.NewApp); every command, subcommand, and flag below is read from the live definitions.
Global flags
aperture declares no persistent global flags. The commonly shared options — --seed, --store, --account, and --principal (the acting principal on mutations, sourced from APERTURE_PRINCIPAL) — are defined per command and appear in each command’s flag table below.
Commands
| Command | Summary |
|---|---|
bestow | Bestow (delegate) a grant you hold to another principal |
bulk | Provision or deprovision many grants in one transactional call |
check | Decide whether a principal may take an action on an object |
delete | Delete an entity (object-type|permission|principal|role|group|account|grant|membership) |
enumerate | List the objects a principal may act on |
explain | Explain why a decision resolved the way it did |
export | Export the whole model to a single JSON/YAML state file (system-admin tier) |
get | Read one entity by id (object-type|permission|principal|role|group|account|grant) |
identifiers | List all valid instance ids of an object type from its provider |
impersonate | Start a time-boxed impersonation session (prints the session) |
import | Apply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier) |
list | List entities of a kind (object-types|permissions|principals|roles|groups|accounts|grants) |
mcp | Serve the read-only Aperture MCP surface over stdio |
put | Create or update an entity (object-type|permission|principal|role|group|account|membership|grant) |
revoke | Revoke a grant you previously bestowed |
serve | Run the Aperture HTTP server |
template | Manage and apply provisioning templates |
aperture bestow
Bestow (delegate) a grant you hold to another principal
aperture bestow [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--delegator | — | string | — | principal bestowing the grant (env: APERTURE_PRINCIPAL) (required) |
--file | — | string | — | path to a JSON grant body |
--json | — | string | — | grant body as inline JSON |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture bulk
Provision or deprovision many grants in one transactional call
aperture bulk <command>
aperture bulk grant
Apply many grants atomically (account-admin tier)
aperture bulk grant [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON array of grant bodies |
--json | — | string | — | a JSON array of grant bodies |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture bulk revoke
Delete many grants atomically (account-admin tier)
aperture bulk revoke [options] [<grant-id>...]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--grant | — | string | — | grant id to revoke (repeatable) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture check
Decide whether a principal may take an action on an object
aperture check [options] <principal> <action> <object>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the decision is scoped to |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture delete
Delete an entity (object-type|permission|principal|role|group|account|grant|membership)
aperture delete [options] <kind> [<id>]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--account-id | — | string | — | membership account id (kind=membership) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--principal-id | — | string | — | membership principal id (kind=membership) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture enumerate
List the objects a principal may act on
aperture enumerate [options] <principal> <action> <pattern>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the enumeration is scoped to |
--limit | — | int | 0 | cap the number of returned object ids (<=0 means the default) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture explain
Explain why a decision resolved the way it did
aperture explain [options] <principal> <action> <object>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | "acme" | active account the decision is scoped to |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture export
Export the whole model to a single JSON/YAML state file (system-admin tier)
aperture export [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--format | — | string | — | output format: json (default) or yaml |
--out | — | string | — | write the state file to this path (default: stdout) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture get
Read one entity by id (object-type|permission|principal|role|group|account|grant)
aperture get [options] <kind> <id>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture identifiers
List all valid instance ids of an object type from its provider
aperture identifiers [options] <object_type>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--exclude | — | string | — | id to omit from the result (repeatable); expands an exclusive allowance |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture impersonate
Start a time-boxed impersonation session (prints the session)
aperture impersonate [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required) |
--mode | — | string | "augment" | augment|become |
--operator | — | string | — | operator principal (env: APERTURE_PRINCIPAL) (required) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
--target | — | string | — | target principal to impersonate (required) |
aperture import
Apply a JSON/YAML state file as an idempotent transactional upsert (system-admin tier)
aperture import [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to the JSON/YAML state file (default: stdin, treated as JSON) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture list
List entities of a kind (object-types|permissions|principals|roles|groups|accounts|grants)
aperture list [options] <kind>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | account to list grants for (required for kind=grant) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture mcp
Serve the read-only Aperture MCP surface over stdio
Exposes Aperture’s decision API (check/enumerate/explain, single + bulk), a read-only what-if simulator, and model inspection as MCP tools over stdio. No tool mutates. Intended to be spawned over stdio by an MCP client.
aperture mcp [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture put
Create or update an entity (object-type|permission|principal|role|group|account|membership|grant)
aperture put [options] <kind>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON entity body |
--json | — | string | — | entity body as inline JSON |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture revoke
Revoke a grant you previously bestowed
aperture revoke [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--delegator | — | string | — | principal revoking the grant (env: APERTURE_PRINCIPAL) (required) |
--grant | — | string | — | id of the grant to revoke (required) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture serve
Run the Aperture HTTP server
aperture serve [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--addr | — | string | ":8080" | TCP address to listen on |
--auth | — | string | — | authenticator adapter: dev|oidc|parsec (overrides APERTURE_AUTH_MODE; defaults to dev — bearer is the principal id, no external IdP) (env: APERTURE_AUTH_MODE) |
--enforce-membership | — | bool | — | deny any decision whose principal is not a member of the active account, before grants are consulted (defence-in-depth; lets shared roles be reused across accounts safely) (env: APERTURE_ENFORCE_MEMBERSHIP) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture template
Manage and apply provisioning templates
aperture template <command>
aperture template apply
Apply a template transactionally into –account (account-admin tier)
aperture template apply [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--id-prefix | — | string | — | prefix for generated grant ids |
--name | — | string | — | template name to apply (required) |
--param | — | string | — | parameter as name=value (repeatable) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
--version | — | int | 0 | template version (0 = latest) |
aperture template delete
Delete a template version, or all versions (system-admin tier)
aperture template delete [options] <name>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
--version | — | int | 0 | template version to delete (0 = all versions of the name) |
aperture template get
Read a template by name (latest version unless –version)
aperture template get [options] <name>
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
--version | — | int | 0 | template version (0 = latest) |
aperture template list
List every template version
aperture template list [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
aperture template put
Create or update a template (system-admin tier)
aperture template put [options]
| Name | Aliases | Type | Default | Usage |
|---|---|---|---|---|
--account | — | string | — | active account (required for system-tier authority resolution) |
--file | — | string | — | path to a JSON template body |
--json | — | string | — | template body as inline JSON |
--principal | — | string | — | authenticated principal performing the mutation (env: APERTURE_PRINCIPAL) |
--seed | — | string | — | path to a JSON/YAML seed model (defaults to the embedded example) |
--store | — | string | — | sqlite DSN for the backing store (defaults to in-memory) |
Deployment
Audience: operators running Aperture as a long-lived service.
Aperture ships as a single pure-Go binary (CGO_ENABLED=0, no external runtime).
Running it as a service is one command — aperture serve — which hand-wires the
whole dependency graph (storage → engine → service → HTTP handler) and boots a
net/http server exposing the HTTP + Twirp API and the admin UI.
bin/aperture serve --addr :8080
aperture serving on :8080
serve listens on :8080 by default and shuts down gracefully on SIGINT /
SIGTERM, draining in-flight requests within a 10-second window before it
forces the listener closed.
Flags
| Flag | Default | Env source | Purpose |
|---|---|---|---|
--addr | :8080 | — | TCP address to listen on. |
--store | (in-memory) | — | SQLite DSN for the backing store. Empty ⇒ ephemeral in-memory store. |
--seed | (embedded example) | — | Path to a JSON/YAML seed model. Empty ⇒ the embedded acme example fixture. |
--auth | dev | APERTURE_AUTH_MODE | Authenticator adapter: dev, oidc, or parsec. The flag overrides the env var. |
--enforce-membership | off | APERTURE_ENFORCE_MEMBERSHIP | Deny any decision whose principal is not a member of the active account, before grants are consulted. |
The generated, always-current flag table is the Command-Line Reference.
The backing store (--store)
--store selects the storage backend:
-
Empty (the default) → the pure-Go in-memory backend. Ideal for demos, CI, and read-only trials; nothing is persisted across restarts.
-
A DSN → the SQLite backend (
modernc.org/sqlite, pure-Go, so CGO stays off). The DSN is amodernc.org/sqlitedata source name — a file path or afile:URL with pragmas, for example:bin/aperture serve --store 'file:aperture.db?_pragma=busy_timeout(5000)'
The SQLite pool is capped at a single connection so writes serialize cleanly
under SQLite’s single-writer model. On startup the server runs the embedded
schema (Setup) and then loads the model from --seed (or the embedded
example) into the store.
Configuration precedence
Aperture is configured, in order of increasing precedence:
.envfile — when you launch throughmake, a.envin the working directory is loaded and its keys are exported into the environment before the binary runs (include .env; exportin theMakefile). This is the dotenv convenience; the binary itself simply reads its process environment.APERTURE_*environment variables — the primary configuration surface. The authenticator is built from these viaauth.ConfigFromEnv(see below).- Command-line flags — a flag that declares an env source overrides that
env var. For example
--auth oidcwins overAPERTURE_AUTH_MODE=dev.
The seed model itself is authored as YAML (or JSON) and supplied with
--seed; that document also carries the providers: wiring the server turns
into a live object-provider registry. Model YAML is data, not process config —
the two are separate.
Authentication environment variables
The authenticator adapter maps each request to an Aperture principal. The
default is dev (the bearer token is the principal id), so serve runs with
no external identity provider out of the box; oidc and parsec are opt-in.
| Variable | Applies to | Meaning |
|---|---|---|
APERTURE_AUTH_MODE | all | Adapter: dev | oidc | parsec. Empty ⇒ dev. |
APERTURE_AUTH_PRINCIPAL_CLAIM | oidc, parsec | Verified claim mapped to the principal id (sub, email, …). Empty ⇒ sub. The dev adapter ignores it. |
APERTURE_OIDC_ISSUER | oidc | OIDC issuer URL. |
APERTURE_OIDC_AUDIENCE | oidc | Expected token audience. |
APERTURE_OIDC_JWKS_URL | oidc | JWKS endpoint for signature verification. |
APERTURE_PARSEC_KEYRING | parsec | Path to the broker’s persisted signing keyring (keyring.json) Aperture verifies brokered tokens against. |
APERTURE_PARSEC_STATE_DIR | parsec | Parsec broker state directory. |
An unrecognised APERTURE_AUTH_MODE fails the boot with APERTURE_CONFIG_INVALID.
The oidc adapter performs network discovery at startup, so misconfiguration
there surfaces immediately when serve boots.
The enforce-membership toggle (--enforce-membership /
APERTURE_ENFORCE_MEMBERSHIP) is defence-in-depth: a non-member of the active
account is denied before any grant is read, which is what lets a single shared
role (manager, analyst, …) be reused across customer accounts without one
account’s grants leaking to another’s members.
Manual dependency injection
serve builds its graph with plain constructors — no DI framework (no
wire/fx/dig). Each layer is a hand-written call:
buildStore(--store, --seed) # storage backend + schema + seed
→ engine.New(store, …) # decision engine (+ scope resolution, membership)
→ service.New(eng, …) # the fully-wired facade
→ server.New(svc) # HTTP + Twirp handlers + admin UI
→ server.Authenticate(authn, …) # request → principal middleware
The same fully-wired facade the mutation CLI commands build is what serve puts
behind a listener — the engine for decisions, the admin gate for tier checks, the
delegation and impersonation services, the append-only audit trail (decisions
sampled at 100 % under serve so the demo trail is legible), the rules engine
over a storage-backed rule source, and the seed’s declared object providers. A
rule saved through the admin UI takes effect on the next decision with no
separate rule store.
Because the wiring is explicit Go, there is no configuration container to learn:
the constructor order in internal/cli/serve.go is the deployment topology.
Related
- serve — the command page, with the flag walkthrough.
- Performance & NFR — the decision hot-path budget and how to assert it.
- Troubleshooting — reading and acting on
APERTURE_*boot/runtime errors. - Command-Line Reference — the generated flag table.
Performance & the NFR
Aperture’s decision hot path carries a hard success metric (FR-31):
p99 cached
Check< 1 ms and ≥ 10 000 checks/sec/instance.
This chapter summarizes how that budget is measured and asserted. The full
methodology, the optimization pass, and the committed hardware numbers live in
the repository at docs/benchmarks.md (repo root, alongside the book — it is
not part of the mdBook source tree, so read it directly in the repo or on GitHub).
The benchmark suite (make bench)
The suite lives in the bench/ package. It seeds a sizable authorization
model — not a three-grant toy — and drives the full decision facade
(service.Service.Check), so the numbers reflect what a real surface pays.
The fixture seeds 8 accounts, 60 roles, 60 groups, and 480 principals, with
overlapping wildcard allows, more-specific deny carve-outs, and 60 concrete
document grants per account. The representative cached Check resolves a
six-subject subject set to roughly 73 applicable grants at differing
specificities, so deny-overrides and the specificity tiebreak genuinely run
rather than short-circuiting.
make bench # go test -run '^$' -bench=. -benchmem ./bench/
make bench is informational — it prints, but never asserts:
| Benchmark | Reports |
|---|---|
BenchmarkCheckCachedAuditOff / …AuditOn | single cached Check ns/op, allocs/op, and a computed p99-ns |
BenchmarkCheckThroughputAuditOff / …AuditOn | sustained parallel throughput as checks/sec |
BenchmarkEnumerateBounded | bounded Enumerate; asserts the result never exceeds engine.DefaultEnumerateLimit |
The audit toggle is the axis: audit-off is the s.audit == nil path;
audit-on wires a sampled (1 %), asynchronous audit.Recorder — the production
shape where decision audit sits off the critical path.
The hard NFR gate (TestCheckNFR)
Wall-clock assertions are environment-sensitive, so the hard gate is a test
that is off by default and never runs in the routine make test. It
self-skips under go test -short and skips unless APERTURE_BENCH_ASSERT=1
is set. Run it explicitly on a known-unloaded machine:
APERTURE_BENCH_ASSERT=1 go test -run TestCheckNFR ./bench/
Inside the gate:
- p99 — time 100 000 cached
Checks on a warm engine, sort the per-op latencies, take the 99th percentile, assert< 1 ms. - throughput — run 200 000 cached
Checks, divide by wall time, assert≥ 10 000 checks/sec(a conservative single-goroutine floor; a real instance parallelises well above it). - both are run with audit on and off.
TestCheckNFR is the regression guard: it fails if p99 ever crosses 1 ms or
throughput drops below the floor. Because it is gated it never flakes the default
build, but it is wired and runnable on demand and in a dedicated CI job/cron
where the runner is known to be idle.
Committed numbers
Measured on an Apple M1 Max (go test -benchtime=2s). Absolute numbers are
hardware-dependent; the durable signal is the headroom and the allocation
profile.
Metric (cached Check) | audit off | audit on |
|---|---|---|
| mean latency | ~66 µs/op | ~70 µs/op |
| allocations | 34 allocs/op | 34 allocs/op |
| p99 (gated, 100k samples) | ~0.275 ms | ~0.265 ms |
| throughput (single goroutine) | ~15 100 checks/sec | ~14 700 checks/sec |
| throughput (parallel benchmark) | ~20 000 checks/sec | ~30 000 checks/sec |
Both targets are met with comfortable headroom — p99 sits ~3.6× under the 1 ms ceiling, and even the single-goroutine throughput clears the 10 k/s floor by ~1.5× before any parallelism. Audit-on does not regress the target: sampling is a single call on the un-kept path and the kept event is built lazily and written asynchronously, so the decision never blocks on audit.
Where the headroom came from
The optimization pass (recorded in docs/benchmarks.md) found the dominant
per-Check allocator: the coverer re-parsed each grant’s object pattern on every
candidate of every Check, so a principal resolving ~73 grants paid ~73 fresh
pattern parses. A concurrency-safe parsed-pattern cache in the engine
(engine/patterncache.go) removed the churn — a parsed pattern is immutable and a
pure function of its source, so a cache hit returns exactly what a fresh parse
would and decision semantics are unchanged. Effect: 172 → 34 allocs/op
(~5× fewer), with the re-parse GC pressure gone from the hot path.
The change was measure-first: caches that already bound their own cost (the compiled-rule cache, the provider metadata cache) were left untouched absent a benchmark showing a win.
Related
- Repository file
docs/benchmarks.md— the authoritative methodology, the optimization write-up, and the latest committed numbers. - Deployment — running the instance whose throughput these numbers describe.
- Rules engine, Providers — the caches referenced by the measure-first note.
Troubleshooting
Audience: operators diagnosing a failed request, boot, or CLI command.
Every failure Aperture surfaces is an APERTURE_* coded error. The code — not
the human-readable message — is the stable contract, and each code carries
operator-actionable fixups in the error registry. Troubleshooting Aperture is
therefore mostly: read the code, look it up, apply its fixup.
Reading an APERTURE_* error
An error prints its code alongside a short summary, for example:
APERTURE_CONFIG_INVALID: auth: unknown auth mode
or, when it wraps a lower cause, the code is preserved from wherever it was first
stamped (the wrappers never re-stamp an already-coded error), so the code you
see is the precise failure class — a provider returning APERTURE_NOT_FOUND for
an absent object surfaces as APERTURE_NOT_FOUND all the way up.
Two properties make the code trustworthy:
- Stable and machine-readable. SCREAMING_SNAKE,
APERTURE_-prefixed. The CLI, HTTP/Twirp, and MCP surfaces map the code to a transport status without string-matching the message. - Leak-free. A code and its message never carry another account’s ids, names,
or contents — cross-account isolation is a hard invariant. Narrowing detail
lives in the structured
Contextmap, not in interpolated message text, so it is safe to log and share the message.
Acting on the fixups
Every code has exactly one entry in the registry (errors.Registry), and that
entry carries either at least one fixup — a concrete next step — or is marked
FixupNotApplicable when no action is meaningful. The generated
Error Codes reference renders the full table:
each code, its canonical message, and its fixups.
The workflow:
- Note the
APERTURE_*code from the output or logs. - Find it in the Error Codes reference.
- Apply the listed fixup(s).
A few codes you are likely to meet operating a service:
| Code | Typical trigger | First move |
|---|---|---|
APERTURE_CONFIG_INVALID | An unrecognised APERTURE_AUTH_MODE / --auth, or bad adapter config at boot. | Check the auth env vars against Deployment; valid modes are dev | oidc | parsec. |
APERTURE_BOOT | serve failed to wire up — store open/Setup, seed load, provider build, or the listener. | Read the wrapped cause; verify --store DSN, --seed path, and the seed’s providers: section. |
APERTURE_UNAUTHENTICATED / APERTURE_INVALID_TOKEN | A request carried no bearer, or a token that failed verification. | Confirm the client credential and the configured adapter (dev treats the bearer as the principal id). |
APERTURE_AUTHZ_DENIED | The caller lacks the admin tier for a gated mutation. | Expected for under-privileged callers; grant the tier or use an authorized principal. |
APERTURE_NOT_FOUND | A referenced grant, rule, object, or entity does not exist (or is out of the caller’s account scope). | Re-check the id; remember cross-account lookups are scoped, so another account’s entity reads as absent. |
The reference table is the authority for the exhaustive list and the exact fixups — the rows above are orientation, not a substitute.
When the fixup is not enough
- The message is a summary; the wrapped cause (visible when the CLI prints the
chain) and the
Contextmap hold the specifics. - Boot failures under
serveare almost always configuration:--storeDSN,--seedpath/format, provider files declared in the seed, or an auth adapter that can’t reach its IdP at startup (oidcdiscovers at boot). See Deployment. - If a decision is slow rather than wrong, that is a performance question —
see Performance & the NFR and the
TestCheckNFRgate.
Related
- Error taxonomy — the coded-error type, the wrapping rules, and the registry gates.
- Error Codes reference — the generated table of every code, message, and fixup.
- Deployment — the config surface most boot errors point back to.
Architecture
Audience: contributors and integrators who want to understand how Aperture is put together before extending it.
Aperture is a policy decision point (PDP): a single engine that answers “is
this principal allowed to do this thing to this resource, and why?” This page
sketches the shape of the codebase and the one tenet that governs it. The
authoritative statement of the project’s conventions is
CLAUDE.md at the
module root — when this page and CLAUDE.md disagree, CLAUDE.md wins. This
page stays deliberately thin so it does not drift from that source of truth.
The one tenet: surfaces are thin translators
There is exactly one place a decision is made. Everything a caller can touch —
the aperture CLI, the Twirp/HTTP RPC API, the MCP server, and the admin UI — is
a thin translator over one decision engine. A surface’s only job is to turn
its wire format into a decision request, hand it to the engine, and render the
result back out. No surface re-implements policy logic.
The payoff is consistency: the answer a shell script gets from the CLI is the
same answer a service gets over RPC and an agent gets over MCP, because all three
ride the same Check / Enumerate / Explain path.
Library-first
The product is the public Go packages at the module root
(github.com/frankbardon/aperture) — not the binary. cmd/aperture/main.go is a
tiny adapter that calls internal/cli.NewApp; it holds no business logic. This
is the “library-first” rule: business logic lives in the root packages, and the
serve command wires them together with manual dependency injection (no
wire/fx/dig).
Package boundaries
flowchart TD
subgraph Surfaces["Surfaces (thin translators)"]
CLI["internal/cli<br/>(urfave/cli/v3)"]
RPC["internal/server + internal/wire/rpc<br/>(net/http + Twirp)"]
MCP["mcp/ + mcp/gosdk<br/>(SDK-free core + adapter)"]
UI["internal/server/static<br/>(admin UI)"]
end
Facade["service/<br/>decision facade"]
Engine["engine/<br/>Check · Enumerate · Explain"]
subgraph Domain["Decision domain (root packages)"]
Rules["rules/"]
Scope["scope/"]
Provider["provider/ · csvprovider/"]
Identity["identity/"]
Model["model/"]
Filter["filter/"]
Auth["auth/ · authz/"]
Audit["audit/"]
Deleg["delegation/ · impersonation/"]
end
Storage["storage/<br/>Storage interface + sqlite/memory"]
Errors["errors/<br/>APERTURE_* coded errors"]
CLI --> Facade
RPC --> Facade
MCP --> Facade
UI --> RPC
Facade --> Engine
Engine --> Domain
Domain --> Storage
Facade -.->|"every failure is a"| Errors
Engine -.-> Errors
Domain -.-> Errors
Read the arrows as “depends on / calls into”. The errors/ package underpins
every layer: every failure that crosses a package boundary is an APERTURE_*
coded error (see Error taxonomy). The dependency graph
points downward — scope, provider, identity, and model are leaves
that the engine adapts to, never the other way round.
The decision API
The engine exposes three operations, each in a single and a bulk-batched form:
| Operation | Question it answers |
|---|---|
| Check | May this principal perform this action on this resource? |
| Enumerate | Which resources/actions is this principal allowed? |
| Explain | Why was a decision reached — which rules and grants applied? |
Explain is first-class, not a debugging afterthought: decisions are auditable
by construction. The Decision API and
Batch operations chapters cover the library surface;
The service facade is the seam every surface
translates into.
Constraints that shape the code
These are hard rules — a change that breaks one is a defect:
- Pure-Go,
CGO_ENABLED=0end to end. No CGO packages (no geo/h3). - No dependency on Pulse. The rules engine renders its AST to an
expr-lang/exprexpression and compiles it in-process. See Rules engine. - No ORM / sqlc / migration tool. Storage is hand-written SQL over
modernc.org/sqliteplus an in-memory implementation behind oneStorageinterface. See Storage. - No bare errors across package boundaries. Wrap in an
APERTURE_*code. - No cross-account leakage through error messages.
Where to go next
- Package layout — what each root package owns.
- Extending Aperture — the “Adding a…” recipes.
- The Update-Demand rule — the docs-with-code house rule.
Package layout
Audience: contributors navigating the source tree for the first time.
Aperture keeps its public packages at the module root (like Pulse) rather
than under internal/. The root packages are the product; the internal/
packages are the surfaces and generators that translate to and from them. Each
row below links the concept chapter that explains the package’s domain in depth —
this page is a map, not a re-explanation.
Root packages (the product)
| Package | Owns | Concept chapter |
|---|---|---|
errors/ | APERTURE_* coded errors; codes.go holds the Registry + AllCodes. The doc-generation source and CI gates live here. | Error taxonomy |
engine/ | The decision engine — Check / Enumerate / Explain, single and bulk. | (drives) Decision API |
service/ | The service facade over the engine; the surface-neutral Query/Overlay/Actor types every surface translates into. | The service facade |
rules/ | The rule AST → expr-lang/expr compiler + program cache. No Pulse import. | Rules engine |
identity/ | Principal and object identities and specificity-ranked patterns. | Identity patterns & specificity |
model/ | The RBAC domain model: object types, permissions, roles, groups, grants. | RBAC domain model |
scope/ | Pluggable scope-strategy resolvers (implicit / inclusive / exclusive) + a registry. | Scopes & scope strategies |
provider/ | The object-provider registry + per-type metadata cache. | Providers |
csvprovider/ | A concrete ObjectProvider backed by a CSV file — the reference implementation. | Providers |
auth/ | Authentication adapters (dev / OIDC / parsec) that turn a bearer into a principal. | Authentication |
authz/ | The authorization gate that surfaces call to guard mutations. | The authz gate |
audit/ | The decision/mutation audit log. | Audit trail |
delegation/ | Grant delegation (“bestow”). | Delegation |
impersonation/ | Scoped act-as-another-principal grants. | Impersonation |
filter/ | Scoped read-visibility filtering of entity lists. | Filtering entity lists |
seed/ | Seed/fixture data and portability import/export. | Seed & portability |
mcp/ | The SDK-free MCP core: typed tool contract + handlers over the facade. | MCP surface |
storage/ | The Storage interface + hand-written SQL (modernc.org/sqlite) + in-memory impl. | Storage |
Internal packages (surfaces and tooling)
| Package | Owns |
|---|---|
cmd/aperture/ | The binary entry point (main.go) and e2e tests. Thin — no business logic. |
internal/cli/ | The urfave/cli/v3 command tree (NewApp); the CLI-reference generation source. |
internal/server/ | net/http ServeMux + Twirp handlers + admin-UI static serving + middleware. |
internal/server/static/ | The admin UI (Alpine + BERA + Rete.js); vendor/rete/ is a committed JS bundle. |
internal/wire/rpc/ | service.proto plus the committed generated service.pb.go / service.twirp.go. |
internal/docsgen/ | The on-demand documentation generators (errcodes, cliref) run by make docs-gen. |
mcp/gosdk/ | The one adapter that imports the MCP protocol SDK; a firewall test keeps the core SDK-free. |
mcp/toolmeta/ | The pure-data tool identity table (names + descriptions) shared by the core and the adapter. |
bench/ | The performance suite and the TestCheckNFR gate (kept out of make test). |
skills/ | The Update-Demand surface docs and their coverage gates. See The Update-Demand rule. |
Dependency direction
The root packages form a layered graph pointing downward. scope, provider,
and identity are leaves: scope imports only identity and errors;
provider imports only identity and errors. The engine adapts the model onto
these leaves rather than the leaves reaching up. This is what lets you add a
scope strategy or a provider without touching the engine — the seams described in
Extending Aperture exist precisely because the dependency arrows
never point up.
Extending Aperture
Audience: contributors adding a new extension point to the engine or a surface.
Each recipe below is a short, concrete how-to grounded in the real code: the files to touch, the interface to implement, and the test or gate you must satisfy. They assume you have read Architecture and Package layout. Every recipe leans on a package’s concept chapter for the why — follow those links rather than re-deriving the domain here.
Run make test (go test ./...), make vet, and make lint before you open a
PR. Some changes also trip the Update-Demand rule — a
surface change that lands without its skills/*.md doc is a CI failure.
Adding an error code
Concept: Error taxonomy. Files: errors/codes.go,
then make docs-gen.
-
Declare the constant in the
constblock inerrors/codes.go. Codes areSCREAMING_SNAKE,APERTURE_-prefixed, typedCode, with a doc comment explaining when it is raised:// APERTURE_WIDGET_JAMMED — the widget resolver could not advance. APERTURE_WIDGET_JAMMED Code = "APERTURE_WIDGET_JAMMED" -
Append it to
AllCodes— the slice every gate walks. -
Add a
Registryentry with aMessageand either at least oneFixuporFixupNotApplicable: true:APERTURE_WIDGET_JAMMED: { Message: "the widget resolver could not advance", Fixups: []string{"retry the request", "check the widget provider health"}, }, -
Regenerate the reference table:
make docs-genrerunsinternal/docsgen/errcodesovererrors.Registryand rewritesdocs/src/reference/error-codes.md(committed; no CI drift gate). Commit the regenerated file with your change.
Gates you must satisfy (in errors/codes_test.go):
TestCodesHaveFixups— every code has a Registry entry with a Message and a Fixup (orFixupNotApplicable).TestRegistryHasNoOrphans— the Registry contains nothing absent fromAllCodes.TestCodesAreScreamingSnakeNamespaced— every code isSCREAMING_SNAKEandAPERTURE_-prefixed.
Construct the error at the raise site with errors.New / Newf / Wrap /
Wrapf (or errors.WithContext for a details map); recover it with
errors.CodeOf. Any error already carrying an APERTURE_* code passes through
verbatim — the wrappers never re-stamp it.
Adding a scope strategy
Concept: Scopes & scope strategies. Files:
scope/ (a new resolver + factory), then register it on your scope.Registry.
A scope strategy decides a grant’s object membership. Implement
scope.ScopeResolver:
type ScopeResolver interface {
Contains(ctx context.Context, object identity.Identity) (bool, error)
Members(ctx context.Context, pattern identity.Pattern) ([]identity.Identity, error)
}
Containsanswers the hot-path question “is this concrete object a member?” and must never enumerate.Membersperforms a bounded enumeration (bounded byscope.DefaultMaxMembers) forEnumerate-style callers. If it needs to list “all objects of a type”, it consults the injectedscope.ObjectLister; when none is configured, returnAPERTURE_SCOPE_LISTER_UNCONFIGURED.
Provide a scope.Factory that validates the parsed scope.Spec for your
strategy and captures the GrantContext + Deps:
func newWidgetResolver(gc scope.GrantContext, deps scope.Deps) (scope.ScopeResolver, error) { … }
Register it under a key on a scope.Registry (Register/MustRegister). Reuse
the built-ins with scope.DefaultRegistry() and add yours, or start from
scope.NewRegistry(). A resolver never computes specificity — that stays the
pattern’s job in the engine. Cover Contains and Members with a table test
alongside scope/resolvers_test.go; an unregistered key surfaces
APERTURE_SCOPE_UNKNOWN_STRATEGY, a bad spec APERTURE_SCOPE_INVALID.
Adding an object provider
Concept: Providers. Files: a new package (see
csvprovider/ as the reference impl), then register it on a
provider.Registry.
A provider is the host’s pull source for one object-type. Implement
provider.ObjectProvider:
type ObjectProvider interface {
Fetch(ctx context.Context, id identity.Identity) (Metadata, error)
List(ctx context.Context) ([]Object, error)
Query(ctx context.Context, filter Filter) ([]Object, error)
}
Fetchreturns an object’sMetadata(amap[string]any); a missing object must return anAPERTURE_NOT_FOUNDcoded error so the Registry can distinguish “absent” from a fault. A plain error is wrapped asAPERTURE_PROVIDER_FETCH.- Return a fresh map per object — cached
Metadatais treated as read-only and is never copied on read, so a shared map would race readers. Listis the unfiltered enumeration;Queryhonours aprovider.Filter(Pattern,Fields,Limit). Aperture re-enforcesPatternandLimiton the results, so a provider that ignores them is still correct, only slower.
Register it under its object-type key on a provider.Registry
(provider.NewRegistry(...)), which pairs each provider with a per-type metadata
cache. A *provider.Registry also satisfies scope.ObjectLister, so it wires
directly into the scope resolvers above. Mirror csvprovider/csvprovider_test.go
for coverage.
Adding an auth method
Concept: Authentication. Files: auth/ (a new
adapter), then select it in the server wiring.
Authentication is always external — Aperture consumes credentials, it never
issues them. Implement auth.Authenticator:
type Authenticator interface {
Authenticate(ctx context.Context, bearer string) (principalID string, claims Claims, err error)
}
- Fail closed: a missing, malformed, or unverifiable credential returns
APERTURE_UNAUTHENTICATED(no principal derivable) orAPERTURE_INVALID_TOKEN(credential failed verification) — never a silently-empty principal. - Resolve the principal through the shared claim→principal mapping so “which
claim is the principal id” stays configuration, matching the
oidcandparsecadapters (thedevadapter is the one exception — the bearer is the principal). Followauth/oidc.go/auth/parsec.goas the models and add a*_test.gobeside them.
The middleware in internal/server extracts the bearer, calls Authenticate,
and attaches the resolved auth.Principal to the request context via
auth.WithPrincipal (recovered downstream with auth.PrincipalFromContext).
Adding an MCP tool
Concept: MCP surface. Files: mcp/toolmeta/meta.go,
mcp/contract.go, mcp/handlers.go, mcp/tools.go, mcp/schema.go.
The MCP core is SDK-free: it imports no MCP SDK. Adding a tool touches the pure-data identity table and the typed contract, and the go-sdk adapter picks it up automatically.
- Identity — add a name constant and a description constant in
mcp/toolmeta/meta.go, and a{Name, Description}row toMeta(). This is the single source of truth both the core and themcp/gosdkadapter read, so they never drift. - Contract — define the typed
In/Outstructs inmcp/contract.go(alias the facade’s surface-neutral query types where possible). Keep the types non-cyclic; a field that would introduce a Go-level cycle must be typedanyso the JSON-Schema reflector stays error-free. - Handler — add a
func(context.Context, *service.Service, In) (Out, error)inmcp/handlers.go. Read-only only: every tool calls a facade READ or DECISION method (Check/Enumerate/Explain/Simulate/Get*/List*); no handler may mutate. - Wire it — add
toolmeta.ToolYours: makeInvoke(handleYours)to the map inmcp/tools.go, and aregister(...)call inmcp/schema.go’sinitso its input/output schemas are reflected.
Gates (mcp/surface_test.go, mcp/firewall_test.go):
TestCatalogMatchesToolmeta— the catalog matchestoolmeta.TestNoMutatingTool— no tool name carries a mutating verb (put/delete/create/update/bestow/revoke/grant/set/remove/write).TestSchemaReflectionClean— every tool’s schema reflects without error.TestMCPCore_NoSDKImport— the core imports no MCP SDK (onlymcp/gosdkmay).
Adding a rule AST node
Concept: Rules engine. Files: rules/ast.go,
rules/compiler.go.
The rule AST is a small, closed node set that is both the engine’s input and the node editor’s serialization target — there is no second rule format, and its JSON form must round-trip byte-identically. To add a node type:
- Declare a
NodeTypeconstant inrules/ast.go(alongsideNodeAnd,NodeCompare,NodeVar, …). Add any fields it needs to theNodestruct. - Validate — add a
casetoNode.Validate()that checks the subtree is structurally well-formed. Validation runs before compilation and is what keeps the rendered expression injection-free, so be strict here. - Render — add a
casetoNode.render()(reached viaNode.Expr()) that emits the node’sexpr-lang/exprspelling. Rendering to an existing operator spelling means ASTs that render to the same expression share a compiled program in the cache.
The compiler (rules/compiler.go) validates, renders, and compiles once per
canonical hash. Keep evaluation pure: expose no wall-clock or random builtins.
If your node calls a function, it must be one of the curated pure functions or
one a host explicitly registers via the rules.Function(name, fn) compiler
option. Add round-trip and render tests beside rules/ast.go.
Adding an RPC
Concept / reference: RPC / HTTP overview,
RPC reference. Files:
internal/wire/rpc/service.proto, the committed generated *.pb.go /
*.twirp.go, internal/server/twirp.go, and the hand-authored RPC reference doc.
- Declare the method (and any new request/response messages) on
ApertureServiceininternal/wire/rpc/service.proto. - Regenerate the Twirp + protobuf code with
make proto(requiresprotocprotoc-gen-go+protoc-gen-twirp). The generatedservice.pb.go/service.twirp.goare committed — CI does not regenerate them — so commit the regenerated files with your change.
- Implement the method on
twirpHandlerininternal/server/twirp.go. The handler is a thin translator: decode the request, resolve the actor/principal from context, call theservice.Servicefacade, and encode the result. ReturnAPERTURE_*coded errors verbatim; never put policy logic here. - Document it — the RPC reference is hand-authored over
service.proto(there is no generator; drift is accepted). Add the new method todocs/src/surfaces/rpc-reference.md. - Cover it — add a smoke test beside the matching
internal/server/*_smoke_test.go.
The Update-Demand rule
Audience: contributors changing a registered surface.
Aperture has one house rule about documentation: any change to a registered
surface must ship the matching skills/*.md document in the same PR. A surface
change that lands without its doc update is a non-skippable CI failure. This page
is descriptive — it explains the rule and the gates that enforce it. The rule
itself lives in skills/update-demand.md and is self-protecting; this page does
not modify skills/*.md or the gates.
Why the rule exists
A surface’s behaviour and its documentation are two halves of one contract. If
they can drift, the docs rot silently and callers get surprised. Update-Demand
removes the option to drift: the same PR that changes a surface changes its doc,
and CI refuses the PR otherwise. The skills/ docs are the human- and
agent-readable description of each surface; keeping them lockstep with the code is
the whole point.
What the rule requires
| If you change… | You must also update… | Enforced by |
|---|---|---|
| An Aperture error code | errors/codes.go — the AllCodes slice and a Registry entry with a Message + Fixups | TestCodesHaveFixups |
A skills/*.md doc | its YAML frontmatter (name matching the file stem + description) | TestEverySkillHasFrontmatter |
| The Update-Demand rule | skills/update-demand.md (it must stay present with frontmatter) | TestUpdateDemandDocPresent |
As real surfaces land, each adds a skills/<feature>.md doc and a coverage gate
in skills/skills_test.go that walks that surface’s registry, plus a row in the
CLAUDE.md table.
The enforcing CI gates
These gates are non-skippable and stay green regardless of any single change — they are the mechanical enforcement behind the rule:
TestCodesHaveFixups— everyAPERTURE_*code has aRegistryentry with aMessageand at least oneFixup(orFixupNotApplicable: true). Adding an error code without its remediation metadata fails here. See Adding an error code.TestRegistryHasNoOrphans— theRegistrycontains nothing that is absent fromAllCodes; the two lists cannot diverge.TestCodesAreScreamingSnakeNamespaced— every code isSCREAMING_SNAKEandAPERTURE_-prefixed.TestUpdateDemandDocPresent— the Update-Demand seed doc (skills/update-demand.md) exists with frontmatter. The rule’s own documentation cannot be deleted without failing CI — this is what makes the rule self-protecting.TestEverySkillHasFrontmatter— everyskills/*.mdhas aname(matching its file stem) and adescriptionin its YAML frontmatter.
How this differs from the doc generators
Do not confuse Update-Demand with the mdBook doc generators. The error-code table
(docs/src/reference/error-codes.md) and the CLI reference
(docs/src/reference/cli.md) are regenerated on demand with make docs-gen and
have no CI drift gate — you regenerate and commit them yourself. Update-Demand
is different: it is a hard CI gate on the skills/*.md surface docs and the error
Registry. The generators keep the reference book fresh; Update-Demand keeps
the surface skill docs honest.
The full conventions catalog, including the authoritative Update-Demand table,
lives in
CLAUDE.md.
Development setup
Audience: new contributors setting up a local Aperture checkout.
Aperture is library-first and pure-Go end to end. There is no code generation on the critical build path, no CGO, and no external services required to build, test, or run the binary. If you can build Go, you can build Aperture.
Toolchain
| Tool | Version | Why |
|---|---|---|
| Go | 1.26.1 | The module targets this toolchain (go.mod). |
CGO_ENABLED | 0 (hard requirement) | Aperture consumes only pure-Go dependencies — it uses expr-lang/expr for rules, modernc.org/sqlite for storage, and has no Pulse dependency. CGO stays off so builds are static and cross-compile cleanly. The Makefile exports CGO_ENABLED=0 for every target. |
| mdBook | v0.5.2 | Only needed to build the documentation site (this book). Not required for the Go build or tests. |
Optional, only if you want the full local gate:
| Tool | Purpose |
|---|---|
staticcheck | Static analysis for make lint. CI installs it; locally make lint degrades to go vet when it is absent. |
protoc + protoc-gen-go + protoc-gen-twirp | Only to regenerate the RPC layer with make proto. The generated code is committed, so you do not need these for a normal build. |
node | Only to rebuild the vendored Rete.js bundle with make vendor-rete. Never required by build/test/CI. |
Building
Clone, then build the binary:
git clone https://github.com/frankbardon/aperture
cd aperture
make build # produces bin/aperture (CGO off, -ldflags="-s -w" -trimpath)
make build is the default goal, so a bare make does the same thing. Run the
freshly built binary with make run, or invoke bin/aperture directly.
make run # build, then execute bin/aperture
make clean # remove the bin/ directory
Because the build is pure-Go, go build ./... also works if you prefer the raw
toolchain — but make build is the supported entry point (it sets the release
flags and keeps CGO_ENABLED=0).
Building the documentation
The book you are reading is built with mdBook. Install mdBook v0.5.2, then:
make docs # mdbook build docs → docs/book/ (gitignored output)
make docs-serve # live-reload preview, opens a browser
make docs-clean # remove docs/book/
Mermaid diagrams render client-side from vendored JavaScript
(docs/mermaid.min.js + docs/mermaid-init.js) — there are no mdBook
preprocessor plugins to install. The built output under docs/book/ is
gitignored; never commit it.
Next steps
- Build, test & lint gates — the commands and CI gates to run before a PR.
- Style & testing conventions — the house rules your code must follow.
- Regenerating artifacts — when and how to run
make docs-gen,make proto, andmake vendor-rete.
Build, test & lint gates
Audience: contributors preparing a change for review.
Run the local gates before every PR. They are fast, pure-Go, and require no services. CI runs the same commands plus the non-skippable gate tests described below.
The make targets
| Target | Runs | Notes |
|---|---|---|
make build | go build → bin/aperture | Default goal. CGO_ENABLED=0, -ldflags="-s -w" -trimpath. |
make test | go test ./... | The full unit/integration suite. Does not include the NFR benchmark gate (see below). |
make fmt | go fmt ./... | Formats the tree. |
make vet | go vet ./... | Standard vet checks. |
make lint | go vet + a static analyser | Runs staticcheck if present, else golangci-lint, else prints a notice and runs vet only. CI installs staticcheck explicitly, so lint is real in CI even though it degrades locally. |
A minimal pre-PR loop:
make fmt
make test
make vet
make lint
The benchmark / NFR gate is separate
make test deliberately excludes the hard performance assertion so a loaded CI
machine never flakes the build. The informational benchmark suite and the gated
NFR test live under bench/:
make bench # informational: ns/op, p99, checks/sec
APERTURE_BENCH_ASSERT=1 go test -run TestCheckNFR ./bench/ # the hard NFR assertion
TestCheckNFR asserts p99 cached Check < 1ms and ≥ 10k checks/sec/instance.
See Performance & NFR and the committed numbers in
docs/benchmarks.md for methodology.
The non-skippable CI gates
Five gate tests protect Aperture’s error taxonomy and its Update-Demand rule.
They are ordinary Go tests, so make test already runs all of them — you do not
need a special command. To run them in isolation:
go test ./errors/ -run 'TestCodesHaveFixups|TestRegistryHasNoOrphans|TestCodesAreScreamingSnakeNamespaced'
go test ./skills/ -run 'TestUpdateDemandDocPresent|TestEverySkillHasFrontmatter'
| Gate | Enforces | Trips when you… |
|---|---|---|
TestCodesHaveFixups | Every APERTURE_* code has a Registry entry with a Message and at least one Fixup (or FixupNotApplicable: true). | Add an error code without its remediation metadata. |
TestRegistryHasNoOrphans | The Registry contains nothing absent from AllCodes. | Add a Registry entry but forget to list the code in AllCodes (or vice versa). |
TestCodesAreScreamingSnakeNamespaced | Every code is SCREAMING_SNAKE and APERTURE_-prefixed. | Name a code apertureFoo or drop the APERTURE_ prefix. |
TestUpdateDemandDocPresent | The Update-Demand seed doc skills/update-demand.md exists with frontmatter. | Delete or de-frontmatter the rule’s own documentation. |
TestEverySkillHasFrontmatter | Every skills/*.md has a name (matching its file stem) and a description. | Add or edit a skills/ doc without valid YAML frontmatter. |
The first three live in errors/codes_test.go; the last two enforce the
Update-Demand rule over the skills/ surface
docs. None of them can be skipped — a red gate blocks the PR.
What CI does not gate
The generated reference pages —
docs/src/reference/error-codes.md and
docs/src/reference/cli.md — have no CI drift gate.
Nothing fails if they go stale. Regenerating them is a manual step you own; see
Regenerating artifacts.
Style & testing conventions
Audience: contributors writing or changing Go code in Aperture.
These are the house rules. The authoritative catalog is
CLAUDE.md at the
repo root; this page summarises what a reviewer will check.
Library-first
The product is the public Go packages at the module root. Every surface — the CLI, Twirp/HTTP, MCP — is a thin translator over one decision engine.
- No business logic in
cmd/aperture/.main.goonly assembles the binary and calls intointernal/cli. Decisions, mutations, and policy live in the root packages (engine/,service/,rules/, …). - New capability goes in the library first; the surface then exposes it.
Errors
Every failure that crosses a package boundary is an APERTURE_* coded error
from the root errors/ package.
- Never return bare
errors.New/fmt.Errorfacross package boundaries — wrap in a coded error viaerrors.New/Newf/Wrap/Wrapf. - Codes are SCREAMING_SNAKE,
APERTURE_-prefixed, declared inerrors/codes.go, listed inAllCodes, and each has aRegistryentry with aMessageand at least oneFixup(orFixupNotApplicable: true). - An error already carrying an
APERTURE_*code passes through verbatim — the wrappers never re-stamp it. Recover the code witherrors.CodeOf. - Do not leak cross-account data through error messages. Messages describe the failure, not another tenant’s data.
Adding a code is a recipe on the Extending Aperture
page, and is guarded by the TestCodesHaveFixups, TestRegistryHasNoOrphans,
and TestCodesAreScreamingSnakeNamespaced gates.
Pure-Go, no CGO, no Pulse
CGO_ENABLED=0is a hard requirement. Do not introduce a dependency that needs CGO (no geo/h3 or other C-linked packages).- No dependency on Pulse. The rules engine renders its AST to an
expr-lang/exprexpression and compiles it in-process. Aperture usesexpr-lang/exprdirectly; it does not import Pulse. - Storage is hand-written SQL over
modernc.org/sqlite(pure-Go) plus an in-memory implementation behind oneStorageinterface — no ORM, no sqlc, no migration tool.
Naming
- No predecessor references — no
Aperture2,LegacyX, or similar. Name for what a thing is now.
Testing
make testrunsgo test ./.... Tests are table-heavy and co-located with the code they exercise (*_test.go).- The server has
*_smoke_test.gosmoke tests; end-to-end tests live incmd/aperture/(e2e_test.go). - Include tests with new functionality. There are no follow-up-PR commitments for test gaps — cover it in the same PR.
- The performance NFR is asserted by
TestCheckNFRunderbench/, kept out ofmake test; run it explicitly (see gates).
Formatting & lint
Run make fmt, make vet, and make lint before opening a PR. make lint
degrades to go vet when no static analyser is on PATH locally, but CI runs
staticcheck, so fix what it reports.
The Update-Demand rule
Any change to a registered surface must ship the matching skills/*.md
document in the same PR — a surface change without its doc update is a
non-skippable CI failure. This is fully described in
The Update-Demand rule.
Regenerating artifacts
Audience: contributors changing error codes, CLI flags, the RPC proto, or the vendored admin-UI bundle.
Aperture keeps three kinds of generated output committed in the repo. None of them is regenerated by CI on the build path, so keeping them fresh is a manual step you own. This page is the checklist for when each one goes stale.
⚠️ There is no CI drift gate on the generated reference docs. The error-code table and CLI reference are regenerated on demand with
make docs-gen. Nothing in CI fails if you forget. If you change an error code or a CLI flag and do not runmake docs-gen, the published book silently goes stale. This is an accepted risk — the mitigation is you, the contributor. Make it a habit.
make docs-gen — generated reference pages
Regenerates the committed reference pages under docs/src/reference/ from the Go
source:
make docs-gen
It emits two files:
| Output | Generated from |
|---|---|
docs/src/reference/error-codes.md | errors.Registry + AllCodes in errors/codes.go |
docs/src/reference/cli.md | the urfave/cli/v3 command tree in internal/cli/ (entry cli.NewApp) |
You must run make docs-gen and commit the result whenever you:
- add, remove, or reword an error code, its
Message, or itsFixups inerrors/codes.go; or - change the CLI — add/remove a command, flag, alias, or usage string in
internal/cli/.
Then rebuild the book to confirm it is clean:
make docs-gen
make docs # mdbook build docs — must succeed with zero errors
Commit the regenerated error-codes.md / cli.md in the same PR as the code
change. Because there is no drift gate, a reviewer cannot rely on CI to catch a
missed regeneration — call it out in your PR description if in doubt.
The RPC reference (
docs/src/reference/ the RPC surface pages) is hand-authored overinternal/wire/rpc/service.proto. There is no generator for it and its drift risk is likewise accepted — update it by hand when the proto changes.
make proto — Twirp / protobuf code
The RPC layer’s generated Go (internal/wire/rpc/*.pb.go and *.twirp.go) is
committed; CI does not regenerate it. Rerun the generator yourself after
editing internal/wire/rpc/service.proto:
make proto
Requires protoc, protoc-gen-go, and protoc-gen-twirp on PATH
(paths=source_relative keeps the output beside the .proto). Commit the
regenerated *.pb.go / *.twirp.go alongside the proto change.
make vendor-rete — admin-UI Rete.js bundle
The committed Rete.js bundle at
internal/server/static/vendor/rete/rete.min.js is rebuilt only by:
make vendor-rete
This is the only target that invokes node, and it is a manual,
occasional step — a version bump or plugin change. It is deliberately not a
dependency of build, test, or CI: the normal build ships the committed blob
and never runs node. All npm work happens in a throwaway temp dir, so no
node_modules / lockfile lands in the repo. See
internal/server/static/vendor/rete/README.md and build/rete/build.sh.
Summary
| Target | Regenerates | Run it after you change… | CI drift gate? |
|---|---|---|---|
make docs-gen | docs/src/reference/error-codes.md, docs/src/reference/cli.md | error codes in errors/codes.go; CLI in internal/cli/ | No — manual |
make proto | internal/wire/rpc/*.pb.go, *.twirp.go | internal/wire/rpc/service.proto | No — manual |
make vendor-rete | internal/server/static/vendor/rete/rete.min.js | Rete.js version / plugins | No — manual |
Pull requests
Audience: contributors opening a PR against Aperture.
This page complements the root
CONTRIBUTING.md,
which is the canonical entry point for the mechanics (branching, bug reports).
The pages in this chapter expand on the parts a first-time contributor most often
gets wrong; where they differ in detail, they are the more specific reference for
that topic. Nothing here contradicts the root guide.
The flow
-
Branch off
main. One feature or fix per PR — keep it focused. -
Make your change in the library first (see Style & testing conventions).
-
Add tests in the same PR. New functionality ships with its tests; there are no follow-up-PR commitments for test gaps.
-
Regenerate committed artifacts if you touched their sources — error codes, CLI flags, the RPC proto, or the Rete bundle. See Regenerating artifacts. Remember the reference docs have no CI drift gate, so this is on you.
-
Update the matching
skills/*.mddoc if you changed a registered surface — the Update-Demand rule makes this a non-skippable CI gate. -
Run the local gates:
make fmt make test make vet make lintIf you changed docs, also confirm
make docsbuilds cleanly. -
Commit, push, and open the PR. Describe the change and note any manual regeneration you performed (since CI cannot verify it).
What CI will check
- The full
go test ./...suite viamake test, including the five non-skippable gates:TestCodesHaveFixups,TestRegistryHasNoOrphans,TestCodesAreScreamingSnakeNamespaced,TestUpdateDemandDocPresent, andTestEverySkillHasFrontmatter(see gates). go vetandstaticcheckviamake lint(CI installsstaticcheck).- Not checked: staleness of the generated reference pages. Regenerate them yourself.
Reviewer checklist
A reviewer will look for:
- Business logic in the library, not in
cmd/aperture/. - Cross-boundary errors wrapped as
APERTURE_*coded errors, with no cross-account data in messages. - No CGO, no Pulse dependency, no predecessor-named symbols.
- Tests co-located with the code and covering the new behaviour.
- Regenerated
error-codes.md/cli.mdwhen error codes or CLI flags changed. - The matching
skills/*.mdupdate when a registered surface changed.
Reporting bugs
Follow the root
CONTRIBUTING.md:
include your config (secrets redacted), Go version, OS, and the full APERTURE_*
code of any surfaced error.