ADR 0114: a two-party credential rotates over two logins

Graduates research 016. Retiring a login is separated from removing a
consumer, which closes a data-loss path in five providers; single-party
secrets rotate in place; the number of parties decides, not the provider.
This commit is contained in:
jochen
2026-09-26 00:22:21 +02:00
parent 942ebe350f
commit 43f63ed41c
5 changed files with 234 additions and 20 deletions
@@ -1,5 +1,8 @@
--- ---
status: active status: graduated
became:
- 02-DECISIONS/0114-a-shared-credential-rotates-over-two-logins.md
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
initiated: 2026-09-26 initiated: 2026-09-26
touches: touches:
- 02-DECISIONS/0113-the-vault-makes-every-secret.md - 02-DECISIONS/0113-the-vault-makes-every-secret.md
@@ -164,10 +164,12 @@ the change using the old value. Where no provisioner can make it, the requiremen
rotatable by the mesh**, and a rotation request is refused, saying why, rather than restarting a service rotatable by the mesh**, and a rotation request is refused, saying why, rather than restarting a service
that would carry on with the old value. that would carry on with the old value.
**How old and new change over is not decided here.** Three mechanisms were measured against every **How old and new change over is decided in [ADR
provider's code in [research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md): 0114](0114-a-shared-credential-rotates-over-two-logins.md).** Three mechanisms were measured against
in place, as the controller's `rotate` does today; two secrets on one login; and two logins over one every provider's code in [research
resource. Its findings bound the choice: 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md): in place, as the controller's
`rotate` does today; two secrets on one login; and two logins over one resource. Its findings bound the
choice:
- every credential provider already re-applies a password in place, so today's rotation works, with a - every credential provider already re-applies a password in place, so today's rotation works, with a
window in which a consumer cannot authenticate; window in which a consumer cannot authenticate;
@@ -178,8 +180,8 @@ resource. Its findings bound the choice:
- every backend can give two logins the same rights over one resource, once the adapter separates the - every backend can give two logins the same rights over one resource, once the adapter separates the
resource from the login. resource from the login.
The mechanism is decided in its own record, on those facts. Until then rotation stays as the On those facts, 0114 rotates a credential two parties hold over two logins, rotates one a single
controller implements it, in place, with its window stated. party holds in place, and separates retiring a login from removing a consumer.
## What this changes in earlier records ## What this changes in earlier records
@@ -215,7 +217,7 @@ On acceptance, each of these is superseded or amended by this record, not edited
- Resolution expands per-consumer requirements from a provision's contract. The contract declares - Resolution expands per-consumer requirements from a provision's contract. The contract declares
them, never the provider's code, so what a provider requires stays predictable from the catalogue. them, never the provider's code, so what a provider requires stays predictable from the catalogue.
- A data provider's adapter gains a return value. What a credential provider's adapter must change for - A data provider's adapter gains a return value. What a credential provider's adapter must change for
rotation is decided with the mechanism ([research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/03-the-options.md)). rotation is [ADR 0114](0114-a-shared-credential-rotates-over-two-logins.md)'s.
- The broker's provisioner gains every bus account, and the controller loses five separate places it - The broker's provisioner gains every bus account, and the controller loses five separate places it
generates a secret today. generates a secret today.
- 54 modules move from own secrets to vault requirements. Six provider clients export a password - 54 modules move from own secrets to vault requirements. Six provider clients export a password
@@ -0,0 +1,198 @@
---
topic: what runs on it
status: proposed
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 0113-the-vault-makes-every-secret.md
---
# 114. A credential two parties hold rotates over two logins; one a single party holds rotates in place; and retiring a login never removes what it reached
## Context
[ADR 0113](0113-the-vault-makes-every-secret.md) decides who asks for a rotation (an operator, or the
vault's policy) and who makes the new value (the vault). It leaves open how old and new change over.
[Research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md) read every provider
in the catalogue against its code:
- **all eight credential providers re-apply a password in place**, on the same login, every time they
run. The controller's `rotate` command relies on that, and states the window it leaves: between the
provider applying the new value and the consumer restarting with it, the consumer cannot authenticate;
- **seven of eight name the consumer's resource after its login**: a database, a bucket, a virtual host,
a key prefix, a topic prefix. Only the forge's npm registry keeps them apart, because an organisation
owns the packages;
- **five of eight destroy the resource when they remove the login**: postgres, mssql, mongodb, minio and
lavinmq. In today's adapters, *retire a login* and *delete the consumer's data* are one call;
- **only one backend holds two passwords on one login**, redis. A second, the forge, holds several
tokens beside one password;
- **every backend can give two logins the same rights over one resource**: a group role, a database
role, a shared policy, shared permissions, a shared role, a shared key prefix, a shared team. No
adapter does it today;
- **administrative credentials have one party and a fixed name.** The provider module is both the one
that applies the value and the only one that reads it. Three backends take it only at first
initialisation, so it can only be changed through a command run with the old value;
- **no module watches a secret.** Every reader reads at start, and the host already recreates a
container when a file it read at creation changes
([issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)).
A first draft of 0113 chose to overlap old and new "through the adapter's existing create and
remove". In five providers, that remove deletes the consumer's data. The mechanism has to be chosen on
what the providers do, and the danger has to be closed whichever mechanism is chosen.
## Considered Options
**1. In place for everything, as today.** Works with every provider unchanged. Rejected for credentials
two parties hold. The window cannot be closed, only shortened, and the two ends are on different
machines with nothing ordering them. A reader the mesh cannot reach, but which still reaches its
provider, is locked out until the mesh reaches it again.
**2. Two secrets on one login.** The applier adds the new password beside the old one. Rejected. It
works for one provider out of eight. Using it where it exists and something else elsewhere is a
mechanism per provider, which is what the mesh is trying to stop having.
**3. Two logins over one resource for everything.** Rejected for credentials a single party holds.
The applier and the reader are the same module, so there is no second party to keep working while the
other moves. A fixed administrative name has no second name to alternate with.
**4. Split by the secret, not by the provider.** A credential two parties hold rotates over two logins;
one a single party holds rotates in place; and retiring a login is separated from removing a consumer
before either is used. Chosen.
## Decision
### Retiring a login never removes what it reached
**A provider's adapter keeps two things apart that today are one:** the consumer's *resource* (its
database, bucket, virtual host, key or topic prefix) and the *login* that reaches it. They get separate
operations:
- **ensure the resource**, named after the consumer;
- **ensure a login** with a value, holding the consumer's rights over its resource;
- **retire a login**, which removes the login and nothing else;
- **remove the consumer**, which is what removes the resource. It is run only when the consumer is
unassigned, as today, and never by a rotation. Whether the resource's data is kept beyond that stays
[ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md)'s.
**The resource is named after the consumer, not after a login.** A consumer's identity is derived from
its assignment ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md)), and today its login
is that same string. So **no existing resource is renamed**. The login every consumer holds today
becomes the first of its two, under the name it already has.
### A credential two parties hold rotates over two logins
This covers a credential between a consumer and a provider, and a bus account, which the broker's
provisioner applies and its module reads. **Each consumer has two logins, derived by the mesh**: its
identity, and its identity with a short fixed suffix. It uses one at a time, and both hold the same
rights over the one resource.
1. **The vault makes the new value.**
2. **Each applier ensures the unused login with it**, with the consumer's rights, and leaves the login
in use untouched. It verifies that the new login authenticates and the old one still does, and
confirms. It repeats the confirmation on every reconcile pass until the vault acknowledges it, so a
lost message costs one pass.
3. **Only then are the readers given the new login and value, together.** The host recreates each
reader, because a file it read at creation changed.
4. **Each reader confirms** by being recreated with the new login and passing its health check, where
its definition declares one.
5. **Only when every reader has confirmed is the old login retired.** Each applier retires it, the
login and nothing else, and verifies that it no longer authenticates.
`status` shows a rotation as waiting on whichever applier or reader has not moved, and it is not done
until the old login is gone. A reader that cannot be reached keeps working on the old login until it
can, and the rotation waits for it. That wait is shown, never hidden.
**Where a backend identifies a connection separately from a login, the two are kept apart.** An MQTT
client identifier must be unique per connection, so the mosquitto adapter derives the connection's
identifier from the consumer and the login in use, and two logins never collide.
### A credential a single party holds rotates in place
This covers a provider's administrative credential and a module's own secret, which only that module
reads. **The vault makes the new value, and the one party takes it:**
- **applied**: the party's provisioner changes it using the old value, then confirms. This is the only
form for a backend that takes its administrative credential only at first initialisation, where a
restart would change nothing;
- **read at start**: the host recreates the party.
There is no window between two parties, because there is only one party. Where neither form can change
the value, the requirement is marked not rotatable by the mesh, and a rotation is refused, saying why
([ADR 0113](0113-the-vault-makes-every-secret.md)).
### One rule decides which
**The number of parties that hold the credential decides, never the provider.** A requirement whose
secret has an applier and a reader in different modules rotates over two logins. A secret held by one
module rotates in place. The resolver knows which from the requirement's recipients, so no definition
declares it.
### Until an adapter can
**An adapter that cannot yet ensure a second login says so**, and the credentials it applies rotate in
place, as today, with the window stated when the rotation is asked for. That is a migration state, not
a second mechanism. It is listed by a check, and the list shrinks to empty. Separating *retire a
login* from *remove the consumer* comes first in every adapter, because it closes a data-loss path that
exists today, whatever rotation does.
## What this changes in earlier records
On acceptance, each of these is amended by this record, not edited:
- [ADR 0113](0113-the-vault-makes-every-secret.md): the changeover it left open is decided here.
- [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md): a consumer's identity leaves room
for the second login's suffix within the tightest backend it reaches, and both logins are checked
against it.
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md): a module's broker
account is two logins with the same permissions over the same queue, one in use at a time. Its scoping
is unchanged.
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): rotation of a two-party
credential is no longer all-or-nothing with a window. It overlaps, with each step confirmed.
Single-party rotation keeps the form to-be 13 describes.
## Consequences
- **Every credential provider's adapter changes**, in two steps. The first separates *retire a login*
from *remove the consumer*, and names the resource after the consumer, which is the name it already
has. The second ensures a second login with the same rights. That is seven adapters for the second
step, since the forge's already holds its packages apart from the user.
- **The SDK's provider harness** carries the alternation, the verification of both logins, and the
repeated confirmation, so no adapter implements them. Its record of what was applied has to survive a
restart of the provisioner mid-rotation. Today it is kept in memory.
- **No consumer module changes.** It reads one login and a value at start, as today, and is recreated
by the host when they change.
- **The derived identity is two characters tighter** in the tightest backend, a minio access key of 20
characters.
- **What got harder:** a provider briefly holds two logins per consumer. A rotation of a two-party
credential lasts until its slowest reader moves, so an unreachable reader keeps the old login valid
until it is reached. And an adapter has four operations where it had two.
## How it is checked
| Rule | Checked by |
|---|---|
| Retiring a login never removes a resource | A provider test per credential provider: retiring one of a consumer's logins leaves its resource and data intact, reachable through the other. |
| Removing a consumer is not a rotation | A harness test: no rotation step calls remove; remove runs only when a contribution goes. |
| No existing resource is renamed | A provider test: a consumer created before the change keeps its resource, and its existing login becomes the first of its two. |
| Both logins hold the same rights | A provider test per credential provider: data written under one login is read and changed under the other. |
| Readers move only after the applier confirms | A rotation test: readers receive nothing until both logins authenticate at every applier. |
| The old login is retired only after every reader confirms | A rotation test with one reader's node unreachable: it keeps authenticating with the old login, the rotation shows waiting on it, and it completes when the reader returns and confirms. |
| A lost confirmation costs one pass | A harness test dropping the first confirmation: the next pass repeats it. |
| A restarted provisioner resumes a rotation | A harness test restarting the provisioner between steps: it resumes from the step it reached. |
| A single-party secret rotates in place | A vault test: a provider's administrative credential is applied by its own provisioner with the old value; a module's own secret recreates the module; neither has a second login. |
| The number of parties decides | A resolution test: a secret with an applier and a reader in different modules is marked for two logins, and one held by one module for in place, with nothing declared. |
| Both logins fit the tightest backend | A controller test: both derived logins for the longest node and module names fit the limit ADR 0049 sets. |
| A connection identifier never collides | A mosquitto provider test: two connections under a consumer's two logins are both accepted. |
| Adapters still in place are listed | A catalogue test lists every credential provider that cannot yet ensure a second login. The list shrinks to empty, and a rotation of their credentials states its window. |
## References
- [Research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md): the survey this
rests on, provider by provider
- [ADR 0113](0113-the-vault-makes-every-secret.md): who asks and who makes
- [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md), [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md),
[ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md): identity, bus accounts, and data outliving
its declaration
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): rotation as implemented
- [Issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md):
why a reader's restart can be derived
+1
View File
@@ -159,6 +159,7 @@ python3 00-META/checks/index.py fail if stale
- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) *(proposed)* - **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) *(proposed)*
- **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md) *(proposed)* - **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md) *(proposed)*
- **0113** — [The vault makes every shared secret, a provider makes resources and data, and the mesh carries both](0113-the-vault-makes-every-secret.md) *(proposed)* - **0113** — [The vault makes every shared secret, a provider makes resources and data, and the mesh carries both](0113-the-vault-makes-every-secret.md) *(proposed)*
- **0114** — [A credential two parties hold rotates over two logins; one a single party holds rotates in place; and retiring a login never removes what it reached](0114-a-shared-credential-rotates-over-two-logins.md) *(proposed)*
### How it is built ### How it is built
@@ -6,6 +6,7 @@ updated: 2026-09-26
decisions: decisions:
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0113-the-vault-makes-every-secret.md - 02-DECISIONS/0113-the-vault-makes-every-secret.md
- 02-DECISIONS/0114-a-shared-credential-rotates-over-two-logins.md
- 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0084-which-provider-serves-a-consumer.md
@@ -248,12 +249,23 @@ process, or a file in a mounted directory. A secret a service takes only at firs
applied by its provisioner or marked not rotatable by the mesh, and a rotation of it is refused rather applied by its provisioner or marked not rotatable by the mesh, and a rotation of it is refused rather
than reported done. than reported done.
**How old and new change over is not settled.** Until it is, rotation stays as the controller does it **How old and new change over depends on how many parties hold the credential**
today: in place, both ends sent in one push, with a stated window in which a consumer cannot ([ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-logins.md), on
authenticate. [Research 016](../../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md) [research 016](../../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md)):
measured three mechanisms against every provider. One constraint holds whichever is chosen: **retiring a
credential must never remove a consumer's resource.** Today's adapters remove both in one call, and in - **Two parties**, a consumer and its provider, or a module and the broker: each consumer has two logins
five providers that deletes the consumer's data. derived by the mesh, both with its rights over one resource, named after the consumer. The vault makes
the new value; each applier ensures the unused login with it and confirms both authenticate; only then
are readers given it and recreated; once every reader has confirmed, the old login is retired. Nobody
is left without a credential that works, and `status` shows who a rotation waits on.
- **One party**, a provider's administrative credential or a module's own secret: in place. Its own
provisioner applies it with the old value, or the host recreates it.
**Retiring a login never removes what it reached.** An adapter keeps *retire a login* and *remove the
consumer* apart. Only unassigning removes the resource, and never a rotation. Today the two are one
call, and in five providers it deletes the consumer's data, so this separation comes first. An adapter
that cannot yet ensure a second login rotates in place, with its window stated, and is listed until it
can.
## Refusing ## Refusing
@@ -302,8 +314,8 @@ Each phase ends at a check that holds, so none of them leaves a mechanism half-r
mesh carries providers' data back. mesh carries providers' data back.
[Issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md) [Issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)
is fixed in the host already. *Ends when* nothing outside the vault generates a shared secret after is fixed in the host already. *Ends when* nothing outside the vault generates a shared secret after
genesis, a lab consumer of analytics receives its site id, and a database credential rotates, the genesis, a lab consumer of analytics receives its site id, and a database credential rotates over its two
vault making the value, the consumer recreated by derivation, and its data intact. logins, the consumer recreated by derivation, never without a working login, and its data intact.
3. **Definitions move, and seats move to assignments.** Every catalogue definition is rewritten, adopted 3. **Definitions move, and seats move to assignments.** Every catalogue definition is rewritten, adopted
and running assignments placed where their data already is, and each claim becomes a seat the and running assignments placed where their data already is, and each claim becomes a seat the
module can hold, held by the assignment that holds it today. *Ends when* the list of definitions module can hold, held by the assignment that holds it today. *Ends when* the list of definitions
@@ -329,15 +341,13 @@ Each phase ends at a check that holds, so none of them leaves a mechanism half-r
| Only the vault generates a shared secret after genesis | A controller test: no code path generates one. An installer test: genesis generates exactly the foundation's first secrets and delivers them to the vault. | | Only the vault generates a shared secret after genesis | A controller test: no code path generates one. An installer test: genesis generates exactly the foundation's first secrets and delivers them to the vault. |
| Only the vault provides `secret` | The parser refuses another provider of it, and resolution refuses a pin on a `secret` requirement. | | Only the vault provides `secret` | The parser refuses another provider of it, and resolution refuses a pin on a `secret` requirement. |
| A provider's per-consumer secret comes from the vault | A resolution test: requiring a database expands to a secret requirement named for the consumer, answered by the vault and delivered to both recipients. | | A provider's per-consumer secret comes from the vault | A resolution test: requiring a database expands to a secret requirement named for the consumer, answered by the vault and delivered to both recipients. |
| Restarts are derived, and rotation keeps data | The tests of [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md): an applied secret restarts nothing, one read at start recreates its reader without a declared restart, and rotating a consumer's credential leaves its resource and data intact. | | Restarts are derived | The tests of [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md): an applied secret restarts nothing, and one read at start recreates its reader without a declared restart. |
| A two-party credential rotates over two logins | The rotation tests of [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-logins.md): retiring a login leaves the resource intact; readers move only after the applier confirms; an unreachable reader keeps its old login until it returns; a single-party secret rotates in place. |
| Refusal names everything at once | A resolution test with three unresolved requirements of different kinds: one refusal naming all three. | | Refusal names everything at once | A resolution test with three unresolved requirements of different kinds: one refusal naming all three. |
| The old forms retire | The catalogue test listing definitions still using one. It must be empty before a form is removed. | | The old forms retire | The catalogue test listing definitions still using one. It must be empty before a form is removed. |
## Not settled here ## Not settled here
- How old and new credentials change over on rotation: in place, as today, or two logins over one
resource, as [research 016](../../01-RESEARCH/016-how-a-credential-can-be-rotated/03-the-options.md)
recommends for credentials with two parties. It is decided in its own record.
- The exact spelling of the one form. It must name a requirement and a field and nothing else. - The exact spelling of the one form. It must name a requirement and a field and nothing else.
- The layout a node's default root uses beneath it, beyond one directory per assignment. - The layout a node's default root uses beneath it, beyond one directory per assignment.