Odel

How the vault works

Addresses, encryption, references, and the audit trail behind Odel's secret storage.

Every credential on Odel — a module's API key, a gateway's OAuth client secret, an upstream access token, an item you created yourself — lives in one place: a service whose only job is holding secrets. It has no public route. Nothing reaches it over the internet; the other Odel services talk to it through direct bindings, and each of them gets its own door.

This page is the mechanism. The vault page covers using it.

Addresses

A secret isn't filed under an id. It's filed under an address that says what it is: a sequence of kind/value pairs, always rooted at the organization that owns it, with values percent-encoded.

What it holdsAddress
A module's secrets, for one userorg/<org>/module/<moduleId>/user/<user> (/profile/<id> for a profile)
A vault item you createdorg/<org>/vault/<name>
A gateway's shared credentialsorg/<org>/gateway/<id>/member/<member>/shared
One caller's upstream tokenorg/<org>/module/<moduleId>/user/<user>, as upstream.<member>.access_token
An org's registered OAuth apporg/<org>/oauthapp/<providerId>

The address is derived from ids the caller already holds — there are no lookup tables, and no reference columns pointing between stores. The upshot is that every authorization question is answerable from the address alone: an address rooted at an organization you aren't a member of is refused before any row is touched.

Two of these are worth reading closely, because the root differs on purpose:

  • A gateway's shared credentials root at the owning organization. That's the OAuth registration everyone connects through.
  • A caller's own upstream token roots at that person's personal organization, not the gateway's owner. An organization's subtree never contains a member's personal key — which is the storage-level reason a gateway can share a registration without sharing anyone's access.

Groups, items, and versions

One address holds a group of named items, so a module needing three keys stores one group with three names rather than three unrelated rows. A vault item is a group of one, stored under the fixed name value — which is what makes item names free-form (spaces and all) while the names inside a group stay environment-variable-shaped.

Every row in a group carries the same version. Any successful write bumps it, and reads hand it back, so a caller that read-modify-writes passes the version it saw and a concurrent write is rejected rather than silently overwriting. Merge-patches require it; a full replace may skip it, since replacing the group is unconditional by definition.

Encryption

Values are encrypted with AES-GCM-256 before they touch storage, using a keyring held by that service alone. Keys are numbered; rotation adds a higher-numbered key, new writes use it, and existing values stay readable under the key that wrote them.

Each ciphertext is bound to its own row with the address and item name as additional authenticated data. Moving a stored value to a different address, or renaming the item around it, breaks authentication — the value fails to decrypt rather than being read somewhere it doesn't belong.

Plaintext exists in exactly two places: inside that service, and in the response it hands the caller. It is never logged, never stored, never attached to an error, and never included in an audit record.

References

An item can hold a pointer instead of a ciphertext: the address and name of a vault item.

That's what a vault pick in a secret field stores. The consuming service — the proxy that runs your modules — writes the pointer without ever having seen the value, and resolves it at read time inside the vault service, which is the only side that can decrypt. The proxy is never granted the vault namespace at all; reference resolution happens beneath it.

Three consequences fall out of storing a pointer rather than a copy:

  • Rotation is one write. Every consumer reads through to the same row.
  • Rename repoints every inbound reference atomically, so a rename can't strand a consumer.
  • Deleting a referenced item is refused, and the refusal names the addresses still pointing at it. There is no cascade that would quietly break something.

One door per consumer

Each service that needs secrets gets its own entrypoint class, and each class hard-codes the namespaces it may touch. The scoping is structural, not configured — a service physically cannot request another's secrets, and a new consumer gets a new door rather than a widened one.

The marketplace API's door is the one worth naming: it can write, list metadata, rename, and delete, and it exposes no read method at all. That's the reason the vault interface can't show you a value. There's nothing to call.

The audit trail

Writes are recorded durably, in the same database batch as the change itself — the record and the mutation commit together or not at all. Reads, denials, and version conflicts stream to analytics instead, where volume matters more than transactional coupling.

lastUsedAt, the "when was this key last used" column in the vault list, is refreshed lazily off the read path and at most once an hour. It's a coarse summary on purpose: a precise one would mean a write on every secret read.

No way out

The vault service needs no outbound network access — its database and analytics are bindings, and its callers arrive over service bindings — so global fetch is replaced with a guard that rejects everything except the error-reporting endpoint.

A keyring that cannot reach the network cannot leak key material, whatever else goes wrong inside it.

Limits

LimitValue
Value length16,384 characters
Items per address64
Item name[A-Za-z0-9_][A-Za-z0-9_.-]*, up to 128 characters
Vault item nameup to 128 characters, spaces allowed
Address1,024 characters

What a module sees

Nothing of the above. A module is handed its resolved secrets at call time on the context object, and learns only their names and values — not the address they came from, not whether the value was a literal or a reference, not what else uses it.

On this page