022 — a credential belongs to a node, so a second consumer refuses

Found while checking whether the module vocabulary covers real use
cases. A node running three modules that all want a database cannot be
planned at all:

  anchor has 3 modules asking for "postgres-database" and they would
  share one credential: gitea, keycloak, umami

The refusal is right about what it says and wrong about what it implies.
They would share one credential, and sharing is worse than refusing —
but the arrangement being refused is the ordinary one, and the node this
mesh exists to take over runs eight modules against one database server.

The cause is the key: a credential is keyed by provision, consumer node
and provider node, so `consumer` is a machine. The provisioner inherits
it and names the role `mesh_<node>`. The refusal is not a check that
caught something; it is the only honest thing that function can do with
a key that cannot tell two consumers apart.

It is the same mistake as 021 with a different face. There the machine
was treated as a trust boundary; here it is treated as an identity, as
though "who is asking" is answered by naming a host. Two modules on one
node are as separate as two on different nodes.

Worth stating plainly: without the refusal, gitea's login would have
opened keycloak's database, and nothing would have said so — from the
provisioner's side it created exactly what it was asked to create.

Not a local fix. It crosses the control plane, the grant file naming and
every provisioner that names something after a consumer.
This commit is contained in:
2026-09-01 02:28:39 +02:00
parent 36d342f176
commit c86adbe3cc
@@ -0,0 +1,81 @@
---
status: located
opened: 2026-09-01
located-in: [mesh-control]
fixed-by:
amended-design:
---
# 022 — A credential belongs to a node and a provision, so a second consumer refuses
## Symptom
A node running more than one module that wants the same provision **cannot be planned at all**:
```
anchor has 3 modules asking for "postgres-database" and they would share one
credential: gitea, keycloak, umami
```
The refusal is correct about what it says. They *would* share one credential, and sharing one is
worse than refusing — a login that opens three databases is not three credentials. But the
arrangement being refused is the ordinary one. **The node this mesh exists to take over runs
eight modules against one database server.**
## Where it comes from
A credential is keyed by *(provision, consumer node, provider node)*:
```go
func (i *Inventory) SecretFor(ctx context.Context, name, consumer, provider string) (Secret, error)
```
`consumer` is a **node**. Everything downstream inherits that granularity: `Grant.Consumer` is a
node, the grant's file is named after a node, and the provisioner names the role it creates after
one — `role := mark + c.Node`.
So the refusal in `ContributionsTo` is not a check that found a problem. It is the only honest
thing that function can do, given a key that cannot tell two consumers apart.
## Why it was not noticed
**Every scenario so far had one consumer per node.** That is the natural shape of a small test —
a consumer here, a provider there — and it is the shape of every lab scenario written to date. A
node with two modules wanting a database is not an edge case discovered by fuzzing; it is what a
real machine looks like, and nothing had modelled a real machine yet.
The refusal also reads as deliberate. It names the modules, explains the consequence, and refuses
rather than picking — the house rule everywhere else. It looks like a decision. It is a limit.
## Why the granularity is wrong
The same argument that closed [`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md).
There, the machine was treated as a trust boundary and containers made that untrue. Here, the
machine is treated as an *identity* — as though "who is asking" is answered by naming a host.
**Two modules on one node are as separate as two on different nodes.** They run as different
containers, on different networks, with different data. A key that cannot distinguish them means
the mesh cannot express the thing it is for.
It also silently weakens what the provisioner does. `mesh_<node>` is one role. Had the refusal not
been there, gitea's login would have opened keycloak's database — and nothing anywhere would have
said so, because from the provisioner's side it created exactly what it was asked to create.
## What a fix has to keep
- **The refusal, where it is still right.** Two modules wanting one provision must not silently
share a credential. After a fix they do not share one, so there is nothing to refuse — but a
genuine collision must still refuse rather than pick.
- **A credential per consuming module**, sealed to the node that holds it. Both facts are needed:
the module is who it is for, the node is what it is sealed to.
- **The provisioner names what it creates after the module**, so a login is traceable to the thing
using it, and so withdrawing one consumer does not remove another's.
- **Withdrawal still works.** A module unassigned must lose its login while the others keep theirs
— which is precisely what one role per node cannot do.
- **Existing single-consumer nodes keep working**, since that is every scenario that exists.
## Scope
This crosses the control plane, the grant file naming, and every provisioner that names something
after `Consumer`. It is not a local fix, and it is the last thing between the current state and a
node that looks like a real one.