Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/json for JSON bodies (the form shown throughout these docs) or application/protobuf for the binary form. Both are the standard Twirp codecs; the JSON encoding is identical to the library's own JSON.
  • HTTP status is always 200 for 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:

RoutePurpose
POST /checkThe 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 /healthzLiveness 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 401 with 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 RPCRequirement
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 codeHTTP
APERTURE_INVALID_INPUT, APERTURE_RULE_INVALID, APERTURE_TEMPLATE_PARAM, …invalid_argument400
APERTURE_UNAUTHENTICATED, APERTURE_INVALID_TOKENunauthenticated401
APERTURE_AUTHZ_DENIED, APERTURE_DELEGATION_DENIED, APERTURE_IMPERSONATION_DENIED, …permission_denied403
APERTURE_NOT_FOUND, APERTURE_RULE_NOT_FOUND, APERTURE_PROVIDER_UNREGISTEREDnot_found404
APERTURE_ENTITY_UNMANAGED, APERTURE_STORAGE_CONSTRAINTfailed_precondition412
APERTURE_UNIMPLEMENTEDunimplemented501
anything elseinternal500

See Error Codes for the full registry.

Why those two are 412 and not 500

Both are mapped out of the internal default on purpose, and the argument is the same in each case: nothing is broken, so nothing should page.

APERTURE_ENTITY_UNMANAGED means the deployment does not manage the entity kind the write targeted — a switch an operator set, working exactly as configured.

APERTURE_STORAGE_CONSTRAINT means the storage layer refused a write that would have broken referential integrity, overwhelmingly a delete whose children are still there: a role a principal still holds, a permission a role still bundles, an account with live memberships or grants. The caller is usually a fully privileged admin who took the teardown steps out of order. A 500 would page an on-call for a mistake the caller made and can fix, and would invite the one thing that can never work — retrying the identical request. A 400 would be closer but still wrong: the request is valid in isolation and becomes legal the moment the children are gone, so the fault is in the deployment's state, not in the message.

412 says exactly that, and the canonical code still rides in meta["code"], so a client dispatching on the code can look up the fixups — which enumerate the children to remove first. See Error Codes and the troubleshooting table.

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\"]}"}'
{}