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_ENTITY_UNMANAGED, APERTURE_STORAGE_CONSTRAINT | failed_precondition | 412 |
APERTURE_UNIMPLEMENTED | unimplemented | 501 |
| anything else | internal | 500 |
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\"]}"}'
{}
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.