AuthBox documentation — Technical documentation

Documentation embedded in this build.

Your own API shape, without a code change

Your applications already consume an API. It has its own field names, its own words for a decision, its own idea of what a timestamp looks like — and none of them are AuthBox's. This page is about getting AuthBox's answers in your shape by bringing two files and mounting them, with nothing compiled and nothing patched.

The short version: you bring your openapi.yaml unmodified, plus one dialect.yaml that maps AuthBox's operations onto yours. A small process — the dialect sidecar — loads both, proves the mapping against both documents before it will serve anything, and then answers at your addresses, in your shapes, with AuthBox deciding every question.

Two things it is not. It is not a template engine: there is no expression, no conditional and no code anywhere in a mapping, which is the whole reason anything can be proved about its output. And it is not a place where decisions happen — the sidecar asks AuthBox as the person it fronts and translates whatever comes back, including a refusal.

The two files

A dialect is a directory with exactly two files in it.

File Whose it is
openapi.yaml Yours, unmodified. The shape your applications already call.
dialect.yaml The mapping: which AuthBox operation answers which of yours, and how each field is carried.

Here is a whole one — the mapping this product's own demonstration composition serves, against a fictional customer's gateway API:

dialect: tideline-gateway/1
source: authbox                # AuthBox's own shipped documents, by operationId
target: ./openapi.yaml         # yours, by operationId
operations:
  - source: reportCan          # AuthBox's operation
    target: listPermissions    # yours
    request:                   # your request -> AuthBox's
      - from: $.user.id
        to: $.subject
      - const: true
        to: $.walk
    response:                  # AuthBox's answer -> yours
      - from: $.subject
        to: $.principal
      - const: authbox
        to: $.source
      - from: $.walk[*].name
        to: $.permissions[*].resource
      - from: $.walk[*].permitted
        to: $.permissions[*].decision
        map: { true: ALLOW, false: DENY }
      - from: $.standing
        to: $.standing
        format: upper
      - from: $.withheld
        to: $.partial
        format: bool→string

Read it as a list of facts about two documents. source: names AuthBox's own operation by its operationId; there is deliberately no way to point it at a file, because a mapping proved against a hand-edited copy of AuthBox's contract would be proved against a contract no deployment serves. target: is your document, and your operationId is what each entry names.

Notice what is absent: no method, no path, no address of any kind. Both sides' addresses are read out of the two documents — including the servers: prefix — so there is no line in any configuration file that can disagree with the contract the mapping was proved against.

What a rule may say

A rule is exactly one of five kinds, and nothing else.

Kind Keys What it does
copy from, to Carries one value across. Same type both sides, or a conversion has to name the difference.
constant const, to Writes a literal your mapping states, so a field the answer does not carry is said rather than invented.
enumeration from, to, map Translates a closed set onto a closed set. Total: a value the map does not name is refused at load, not at request time.
conversion from, to, format One of the six conversions below. No entry takes an argument.
omission omit, to Leaves a target field out with the reason written down. It counts as coverage only where your document does not require the field.

A key this grammar does not declare is refused by name rather than ignored, because a line nobody reads is a line whose author believed it was doing something. Two kinds in one rule are refused too: which of them won would be a rule about precedence, and a rule about precedence is a program.

The six conversions are rfc3339→epoch_seconds, epoch_seconds→rfc3339, upper, lower, dn→cn and bool→string, spelled with that arrow exactly. The ASCII -> is refused with a message naming the right form: a list with two spellings per entry is a list the loader, this page and the register can disagree about.

What a path may say

A path is $, then one or more steps. A step is .name, where a name matches [A-Za-z_][A-Za-z0-9_]*, optionally followed by [*] — and at most one [*] in a path.

That is all of it. There is no index, no filter, no slice, no recursive descent, no wildcard and no quoted key, and each absence buys the same thing: every one of them is a claim about an instance that a schema cannot settle, so a rule using one could not be checked before a request arrived. Two [*] would address a set of sets, and mapping one onto another needs a rule about how the nesting corresponds.

A from carrying [*] must be paired with a to carrying one, and the translation is then elementwise: your array comes out the same length and the same order. Two such rules writing into one array — the ordinary case, as in permissions[*].resource and permissions[*].decision above — merge into one set of rows; two that produce arrays of different lengths are refused when it happens, because the rows of one answer correspond and two lengths leave which row belonged to which unstated.

One absence is load-bearing rather than merely austere: a property name cannot carry a hyphen, so this grammar has no spelling for an HTTP header at all. See rule 5.

The six rules a mapping is proved against

Every mapping is checked at load, against both documents, in this order. A mapping that fails any of them is a refusal to start naming the rule and the line — never a translation somebody discovers in production.

1. Every path exists on its own side, with a compatible type. Your to paths are resolved in your document and AuthBox's from paths in AuthBox's, step by step. A path into an object whose members the document does not declare is refused even where additionalProperties would permit one: "exists in the schema" is a claim about a declaration.

2. Every required field is produced by exactly one rule. Every field your response document requires has a rule that writes it, and every field AuthBox's request requires has one that feeds it. Required is read transitively — a document requiring grant inside an optional reason requires nothing of an answer with no reason at all.

3. A verdict maps only onto a verdict. Where a rule reads AuthBox's own answer — permitted, allowed, decision, authorizes — the field it writes must be able to carry both outcomes and nothing else: a boolean your document does not narrow to one value, or a two-value enumeration with a total map. A verdict copied into a free-form string, or into a flag your document pins to one value, is refused. Your applications read that field as a decision, and a field that reads the same whichever way the answer went is the one shape a decision may not have.

4. Nothing the fence may withhold feeds a field you require. AuthBox withholds what a caller is not cleared to be told, so a finding can legitimately be absent from an answer. A rule carrying such a field into a field your document marks required is refused, because the only way to satisfy your document then would be to invent a value.

5. The marking is not a field. X-AuthBox-Marking is carried by the sidecar as a header, byte for byte, on an answer and on a refusal alike; a mapping cannot name it, move it or rename it. This is partly structural — the path grammar cannot spell a hyphen — and partly checked: a rule reaching a body property called headers, or spelling the header's name without its hyphens, is refused, and so is a target operation that declares that response header as anything but a string. An operation that does not mention it at all is accepted; saying nothing is not a refusal.

6. No rule reads a receipt. A signed receipt is a statement whose signature covers its own bytes, and a re-shaped one verifies against nothing. A from path that touches a receipt carrier is refused, and a claim name below such a carrier makes the refusal say which claim its author was reaching for.

Those six are what the word "proved" means here, and its limit is worth stating plainly: they are claims about the two documents. They say that every instance AuthBox can emit for that operation translates into an instance your schema accepts, that no rule can widen a decision, and that nothing can shed a marking. They say nothing about whether your document describes what your applications actually do.

Which parts of your document can be mapped

The adapter that reads an OpenAPI document is deliberately small and loud. It reads $ref (local, to #/components/schemas/), type, nullable, properties, required, items, enum, format, description and additionalProperties.

It refuses, naming the keyword: oneOf, anyOf, allOf, not, if/then/else, discriminator, patternProperties, dependentSchemas, dependentRequired, propertyNames, prefixItems, contains, unevaluatedProperties, unevaluatedItems, $dynamicRef; a $ref that leaves the document; a type naming more than one non-null type; and a $ref cycle.

Every one of those is the same argument in a different keyword. A composition means the shape of an instance depends on which branch matched, so "does this path exist, with this type" has no single answer — and a mapping proved against one branch is unproved against the other.

The refusals are lazy, which matters in practice: only the schemas a mapped operation's request and response actually reach are resolved. Your API may use any of those keywords anywhere else it likes. You are refused for the shape being mapped, never for the rest of your document.

If the shape you need genuinely requires one of them, or requires computation between the two shapes, that is code, and the honest home for it is a private adapter on sdk/foreign — out of this tree, under the no-widening harness that already fences the policy direction the same way (plan 138 §3.4). A mapping that had grown an expression language to cover the case would have taken the provability of every other mapping with it.

Running it

The sidecar is one process per dialect, configured by one file and no flags. It sits behind the front door exactly as any application does — a route, a description, an icon, a tile in the directory — and it holds no state, caches nothing and decides nothing.

authbox-dialect -config /etc/authbox/authbox-dialect.yaml

The configuration names the dialect directory (dialect.dir), the address it listens on (dialect.listen), the AuthBox listener it asks (dialect.upstream.url, with dialect.upstream.ca, dialect.upstream.cert and dialect.upstream.key), the certificate it serves with (dialect.serve.cert, dialect.serve.key, dialect.serve.client_ca), and the peers whose asserted identity chain it will accept (dialect.delegators[].subject and dialect.delegators[].issuer — the front door, so the question travels as the person at the browser). Every key is a registered setting with a tier and a note, and an unrecognised key is refused rather than ignored: a misspelled delegators: that was quietly dropped would make this sidecar ask AuthBox about the door on every request and answer every one of your clients with the door's own standing.

Before a deployment, run the mapping through -validate:

authbox-dialect -config /etc/authbox/authbox-dialect.yaml -validate

It loads the configuration, proves the mapping against both documents, prints the route table and exits — all of it before a credential is opened or a listener is bound. That order is what makes it worth running: it needs no PKI, no network and no AuthBox, so it runs inside the image on a host where nothing has been staged yet, which is every deployment at the moment the check is useful. What it prints is the route table, which is the mapping's own operation list: your method and path on one side, AuthBox's on the other, prefix included.

Two things it will tell you that are easy to get wrong otherwise. An address your mapping does not name is a 404 from the sidecar and never a pass-through, because forwarding what it did not recognise would put an unproved translation in front of AuthBox's own surface. And if one of your operations claims an address something else already claims, the collision is a refusal at load: choosing between two of your operations is not this process's decision to make.

Your name and your address

Two settings are yours, and neither of them moves what is enforced.

dialect.service.name is what your people call this API. It goes in the sidecar's start-up line, in its liveness body and on every exchange it logs, so an operator on a host fronting several dialects can tell from one line which customer's API refused. It defaults to your document's own info.title, because the document already states the name — set it only where the name your operators use differs from the one the document was written with. The front door's tile beside it is the route's own description, and the demonstration keeps the two equal on purpose: a deployment whose tile and whose logs call one door two things is a deployment telling a visitor and an operator different stories.

dialect.service.base_url is the prefix those operations are served at here. Your document was written for wherever you serve it — https://iam.example.com/api/v2, say — and this deployment may put it behind a host of its own at /. Setting it moves the sidecar's mux and nothing else: the old prefix stops answering rather than answering as well, and two of your operations the new prefix would bring to one address are refused at load rather than resolved by order. A value carrying a scheme or a host is refused, because which host reaches this sidecar is the front door's business (routes[].host), and a mux pattern with an authority in front of it matches on that host.

One operation now has two addresses, and knowing which is which is the whole of it. The mux moves under dialect.service.base_url. The authorization map does not: AuthBox merges a document named under spec.foreign (below) under the prefix the document states. So a sidecar can serve /gateway/access/check and ask AuthBox about /v2/access/check, and that is correct — the mapping addresses operations by id and bodies by path, never by URL, so neither setting can move what is asked or what is enforced.

Your labels

Your document may carry AuthBox's own x-authbox blocks — the requirement grammar AuthBox's own operations are declared with — and AuthBox will enforce them. The requirement then lives in a file you own and review in your diff, in the grammar the rest of the deployment already uses:

paths:
  /permissions:
    post:
      operationId: listPermissions
      x-authbox:
        mission: authbox-read
        group: "CN=operators,O=authbox,C=US"
      x-authbox-conformance:
        satisfies: "CN=admin,O=authbox,C=US"

Two lines make it real, one on each side.

On AuthBox, spec.foreign names your document: a list of authorization maps for operations declared here and served elsewhere. The writer loads each one with the same parser and the same validation as its own document — a requirement naming a mission, a group or an attribute this deployment does not hold refuses to start, rather than denying quietly later — merges its operations into the one authorization map under your document's own servers[0].url prefix, and mounts a handler for none of them. It is not spec.surfaces: that setting describes routes this listener serves and is checked against what is actually mounted, and folding a foreign document into it would make your API indistinguishable from a surface somebody forgot to mount.

On the sidecar, dialect.enforced says to ask. Before it translates a single field, the sidecar puts authzenEvaluation to AuthBox as the person — the same chain, the same credential — naming your operation's method and the path the authorization map declares. A yes, and the exchange proceeds as before. A no, and your client receives a 403 carrying AuthBox's own bytes, unedited and untranslated: the sidecar splits the status from the body because a refusal arrives from AuthBox as a 200 with a decision of false, and a client reading that as an answer would be reading a body your own document does not declare. Your document's own word for a denial never appears, because a verdict out of a response mapping that never saw an answer is a denial this deployment never made.

The setting is a flag rather than something inferred, and the two files are held in step by the report rather than by anybody remembering. Inferring the posture from whether your document carries blocks would stop the ask the day you removed one — deciding, silently, that an unlabelled operation was open.

Three things to know before you write the first block.

x-authbox-conformance is the other half, and it costs one line: name a subject your requirement must permit, and your operation joins the generated conformance suite (the one AuthBox holds its own operations to). A subject that satisfies is answered in your shape; one that fails a clause is refused; and both are driven through the sidecar, because that is the only address your operation has.

What labels cannot do is carry a marking. A requirement is what they express; the marking on an answer is whatever AuthBox's answer carried, unchanged.

Three claims, and why they are not one

A proved mapping, an answerable question and an enforced label are different things, and this is the part worth reading twice before you deploy.

"This mapping proves" is about the documents: the six rules pass, and generated instances of AuthBox's own schemas translate into instances your schema accepts. It is checked at load, every time, and it is what -validate reports.

"This deployment can answer it" is about your deployment: whether a question can actually reach that operation through the sidecar. That depends on things no pair of documents mentions — whether a route fronts the sidecar and asserts the caller's chain, whether AuthBox registers this sidecar as a delegator and how narrowly that entry is scoped, and what the operation's own declared requirement demands of every hop of the relayed chain. An operation whose requirement names a group the front door is not a member of is denied at the door's own hop, however perfect the mapping is.

"The customer's own labels decide" is about whose rule applies: whether the writer declares your document under spec.foreign and the sidecar is dialect.enforced, so that the x-authbox block you wrote is what admits or refuses a caller. Without both, what decides is the front door's route and AuthBox's own operation behind the translation — which is a perfectly good posture and the one this product shipped first.

All three claims are generated for every dialect this repository declares, per operation, in DIALECTS.md — with the instance counts behind the first, the reason behind the second, and the two files and one block behind the third. A mapping that proves and cannot be asked here is a mapping for a deployment shaped differently; one that is answerable and not enforced is the original posture. Neither is a defect, and the report says which is which rather than running them together.

What you get back, exactly

The answer is AuthBox's, about the person who asked. The sidecar puts the caller at the head of the chain it sends and appends itself at the tail, so AuthBox decides about the person at the browser and not about the sidecar. Asking as itself would produce a real answer to a question nobody asked — and no field in your shape could tell your application that.

The marking travels untouched. Every value of the marking header the AuthBox answer carried is copied onto the answer you receive, in order, byte for byte, on a refusal as well as on an answer. The sidecar never states one of its own: a fabricated label is worse than a dropped one, because a door in front would compare it and release on it.

A refusal stays a refusal. A non-2xx from AuthBox is passed through with AuthBox's own status and AuthBox's own words, with no mapping applied. Running a response mapping over an error envelope would at best produce nothing and at worst produce {"decision":"DENY"} where AuthBox said "you may not ask this" — which is a denial this deployment never made.

A translation that cannot be made is not a decision. Three things are refused rather than defaulted: a source field the answer did not carry feeding a field your document requires, a value outside a map's enumeration, and a value a conversion cannot read. Each arrives as a plain refusal in the translator's own words, never as a body in your answer shape — because a body in that shape is a body your application reads as a decision.

Where to read on

The support matrix names the settings and the tests behind the sidecar. DIALECTS.md is the generated conformance report for every dialect in this tree. Plan 173 is the design, the open questions and what building it found, and plan 175 is the round that added your name, your address and your labels.