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 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.

RPCRequest → ResponsePurpose
CheckCheckRequestDecisionIs principal allowed action on object in account? Returns allow, a reason, and the deciding grant ids.
CheckBatchCheckBatchRequestCheckBatchResponseMany Checks in one call; results are index-aligned, each either a Decision or a per-item error code+message.
EnumerateEnumerateRequestEnumerateResponseWhich object ids matching pattern may principal take action on? Optional fields (an object-metadata filter, below), optional references (reference edges, below) and limit.
EnumerateBatchEnumerateBatchRequestEnumerateBatchResponseBatched Enumerate; index-aligned results. Each query embeds an EnumerateRequest, so fields and references are per-query.
ExplainCheckRequestExplainResponseThe full decision derivation for a query, as trace_json (the recursive engine Trace, not modelled in proto).
ExplainBatchCheckBatchRequestExplainBatchResponseBatched 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"] }

EnumerateRequest.fields — the object-metadata filter

EnumerateRequest carries an optional fields map (field 6, map<string, google.protobuf.Value>) that narrows the result by object metadata. Omitting it filters nothing, so an existing client is unaffected.

curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
  -H 'Content-Type: application/json' \
  -d '{"account":"acme","principal":"alice","action":"read","pattern":"account:acme/**",
       "fields":{"tier":"premium","seats":5,"brands":"brand:Y"},"limit":50}'

Semantics — the same contract a provider's own Filter.Fields obeys, evaluated by the same code, so a filtered enumeration and a provider-side query select the same objects:

RuleMeaning
AND across keysEvery predicate must hold.
Membership on collectionsA list-valued metadata field matches when it contains the wanted value ("brands": "brand:Y"). A list-valued want is a container compared by equality.
Absent never matchesAn object whose metadata lacks the key is excluded — not even a null want matches it.
Typed comparisonNumbers compare across numeric types by value (5 matches an int64 5), but the string "5" never matches the number 5, and vice versa.
Filter before limitCandidates are decided, then filtered, then truncated. Truncating first would return the matches among the first limit candidates rather than the first limit matches — a silently wrong answer.
Filter only subtractsThe predicate runs on candidates that already survived deny-overrides, so it can never surface an object Check would deny.

Why Value and not a JSON string. The predicate compares by type, so the string/number/bool/list distinction has to be visible in the schema — a JSON blob in a string field would be opaque to every generated client and unvalidatable at the boundary. Each Value kind maps to exactly one shape, recursively, with no coercion between kinds: null→absent-ish nil, number→float64, string→string, bool→bool, list→array, struct→object.

Caveat: integers beyond 2^53. google.protobuf.Value carries every number as a double, so an integer larger than 2^53 loses precision in transit. Send such a key as a string and store it as a string in metadata. Nothing in the conversion can repair the loss — it is a property of Value itself. (internal/wire/rpc's TestFieldsRoundTrip_LargeIntegerLosesPrecision pins this, and fails loudly if it ever stops holding.)

Rejections are APERTURE_INVALID_INPUT / 400, never a dropped predicate — a dropped predicate widens the result, and a filter that silently widens is a filter that authorizes:

  • a non-finite number (NaN, ±Inf). It would otherwise become the string "NaN", an unsatisfiable predicate that reads as "no access". Note protojson cannot marshal a non-finite Value at all, so only the binary codec can reach this guard;
  • a Value with no kind set (unreachable from a generated client, reachable from a hand-rolled one).

In EnumerateBatch a malformed predicate fails only its own item — that item carries the error code/message and never an empty object_ids, and the rest of the batch runs.

Wiring: the server must have an object-metadata source (the same provider registry that backs object listing). Filtering a type with no registered provider, or with no source configured at all, is APERTURE_PROVIDER_UNREGISTERED / 404 rather than an empty list — an empty list would read as "no access" and hide the misconfiguration. Because the predicate runs per candidate, that error only appears when the enumeration has at least one allowed candidate. An object the provider has no row for is simply excluded (every field absent, and absent never matches).

EnumerateRequest.references — the reference edges

EnumerateRequest also carries an optional references list (field 7, repeated ReferenceEdge) that restricts the result to the identities a holder object's declared reference field contains — "the brands in dataset X". Omitting it restricts nothing, so an existing client is unaffected. EnumerateBatchRequest embeds EnumerateRequest, so edges ride per query.

message ReferenceEdge {
  string holder_type = 1;  // OPTIONAL; empty means holder_id's terminal segment type
  string holder_id   = 2;  // required — "account:acme/dataset:7"
  string field       = 3;  // required — a DECLARED reference field
}
curl -s -X POST http://localhost:8080/twirp/aperture.ApertureService/Enumerate \
  -H 'Content-Type: application/json' \
  -d '{"account":"acme","principal":"alice","action":"read","pattern":"account:acme/brand:*",
       "references":[{"holder_id":"account:acme/dataset:x","field":"current_brands"}]}'

It is a dereference, not a filter, and the two are not interchangeable. fields answers "which datasets contain brand Y?" — the dataset holds the field, so a predicate on dataset expresses it. references answers the mirror image, "which brands belong to dataset X?" — a brand holds no field naming its datasets (references are declared on the holding side only), so no predicate on brand can express it at all.

RuleMeaning
Several edges AND"the brands in dataset X and in campaign Z".
Composes with fieldsBoth apply; they are independent.
Before limitRestriction → decision → filter → truncate.
Only subtractsEdges apply to candidates that already survived deny-overrides, so no edge can surface an object Check would deny.
Exactly one hopThe identities an edge yields are never themselves dereferenced.
holder_type is optionalEmpty means "whatever holder_id's terminal segment type is". When given it must agree with holder_id.

The security semantics are the point, and they are asserted over a real JSON round trip — a boundary that turned an empty result into a 404, or a 404 into an empty list, would silently change what the server discloses about objects it never let the caller see:

SituationResponse
the principal may not read the holderHTTP 200 with an empty object_ids — never 403, never 404. "You may not see dataset X" and "dataset X contains nothing you may see" must be indistinguishable, or the edge is an oracle for objects the caller was never allowed to know about.
the holder is absent, inside account, caller is a memberAPERTURE_NOT_FOUND / 404 — the ergonomics a typo deserves, confined to a caller already inside the account.
the holder is outside accountempty, whether or not it exists. This is the disclosure boundary: a caller in one account never learns what does or does not exist in another.
the caller is not a memberempty, always — membership is decided before the holder is looked up, so a non-member never sees a 404.
a referenced identity no longer existsskipped, with an operator-side warning log. An application-level foreign key has no database constraint behind it, so one deleted brand must not fail every decision on the dataset that still lists it.
the field is not a declared referenceAPERTURE_PROVIDER_REFERENCE_INVALID / 400 — loud, never an empty list. A wiring fault describes the deployment, not the data; an empty list would read as "no access" to a client that cannot see the deployment.
the holder_type has no registered providerAPERTURE_PROVIDER_UNREGISTERED / 404 — likewise loud.
a malformed edge (empty holder_id or field, an unparseable identity, a holder_type that disagrees)APERTURE_INVALID_INPUT / 400, before any storage or provider work.

APERTURE_PROVIDER_REFERENCE_INVALID is a 400, not the 500 the default mapping would give it: the only way a client reaches it is an edge naming an undeclared field — its own input, which no retry can fix — and 5xx is the one class a client is entitled to retry and an alert rule is entitled to page on. The Aperture code is in meta["code"] either way.

In EnumerateBatch each item keeps its own answer: one query's 404 never becomes another's, and a fail-closed empty result is reported as an empty list rather than an error.

See Object references for the declaration side, and the CLI's --via for the same query from a terminal.

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.

EntityPut (system)Get (auth)List (auth)Delete (system)
Object typePutObjectTypeGetObjectTypeListObjectTypesDeleteObjectType
PermissionPutPermissionGetPermissionListPermissionsDeletePermission
PrincipalPutPrincipalGetPrincipalListPrincipals¹DeletePrincipal
RolePutRoleGetRoleListRolesDeleteRole
GroupPutGroupGetGroupListGroupsDeleteGroup
AccountPutAccountGetAccountListAccounts¹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 (ObjectIdentifiersRequestObjectIdentifiersResponse, 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.

RPCRequest → ResponseAuthPurpose
PutGrantEntityRequestEmptyaccountCreate/replace one grant (model.Grant as entity_json).
GetGrantGetRequestEntityResponseauthRead one grant, visibility-scoped to the caller.
ListGrantsListGrantsRequestEntityListResponseauthGrants in account_id, with optional Filter.
DeleteGrantDeleteRequestEmptyaccountDelete one grant by id.
PutMembershipEntityRequestEmptyaccountAdd/replace a principal's membership in an account.
DeleteMembershipMembershipKeyRequestEmptyaccountRemove 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.

RPCRequest → ResponseAuthPurpose
PutRuleRuleRequestEmptysystemPersist a rule definition.
GetRuleGetRequestRuleResponseauthRead one rule as rule_json.
ListRulesEmptyRuleListResponseauthEvery stored rule, each as JSON.
DeleteRuleDeleteRequestEmptysystemDelete a rule by id.
ValidateRuleRuleRequestEmptyauthCompile/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.

RPCRequest → ResponsePurpose
SimulateSimulateRequestDecisionThe 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.
SimulateExplainSimulateRequestExplainResponseSame overlay, returning the full Explain trace.
EvaluateRuleEvaluateRuleRequestEvaluateRuleResponseRun an UNSAVED rule AST directly against one object's provider metadata (no account/principal/grant); returns the boolean result, the object metadata snapshot the rule saw (object_json), the reference instant it resolved relative dates against (now), what each relative-date operand became at that instant (bounds_json), and the evaluation's deny-safe notes (notes_json).

Templates

Reusable grant bundles. Definition writes are system-tier; apply is account-tier (it materialises grants into a target account).

RPCRequest → ResponseAuthPurpose
PutTemplateEntityRequestEmptysystemPersist a template definition.
GetTemplateTemplateKeyRequestEntityResponseauthRead template (name, version); version <= 0 selects the latest.
ListTemplatesListRequestEntityListResponseauthList templates, with optional Filter.
DeleteTemplateTemplateKeyRequestEmptysystemDelete by name/version (version <= 0 deletes all versions).
ApplyTemplateApplyTemplateRequestEntityListResponseaccountExpand 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.

RPCRequest → ResponsePurpose
BulkPutGrantsBulkGrantsRequestEmptyCreate/replace many grants (grants_json) in one transaction.
BulkDeleteGrantsBulkDeleteGrantsRequestEmptyDelete many grants by id in one transaction.

Declarative state (system-tier)

Whole-model portability.

RPCRequest → ResponsePurpose
ExportExportRequestExportResponseSerialize the entire model to one declarative state file as document_json (a system-tier read).
ImportImportRequestEmptyApply a state file (document_json) as an idempotent, transactional upsert (the most privileged mutation; system-tier).

Audit query (gated read)

RPCRequest → ResponsePurpose
QueryAuditQueryAuditRequestQueryAuditResponseThe 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.

RPCRequest → ResponsePurpose
BestowBestowRequestEmptyHand on a subset of the delegator's own grants (grant_json).
RevokeRevokeRequestEmptyRevoke 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.

RPCRequest → ResponsePurpose
ImpersonationStartImpersonationStartRequestImpersonationSessionBegin impersonating target in account under mode (augment or become); returns the session with started_at / expires_at (RFC3339).
ImpersonationStopImpersonationStopRequestEmptyDiscard a session (client-side; echoed for symmetry/audit).