Files
hq/04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md
T
jschoubben 36d9b38a0d An issue is open, diagnosing, located, resolved or wontfix — nothing else
The playbook, the README and the status skill knew five statuses; the cycle check
knew a sixth, 'fixed', and not 'wontfix'. Eleven issues sat in the sixth for weeks
with their fixes shipped, one step short of closed. They are resolved; the check
refuses the word from now on and accepts the one the playbook allows.
2026-09-21 17:38:17 +02:00

114 lines
5.5 KiB
Markdown

---
status: resolved
opened: 2026-09-01
located-in: [mesh-control]
fixed-by: mesh-control 0af3ea1
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
```
**And that is only the loud half.** The consuming node does not refuse at all. Three modules
wanting one database produce **one** need:
```
modules=3 needs=1
name=postgres-database from=anchor for=gitea
```
So the first module gets a credential, the other two get no file at all, and each starts and fails
to authenticate with nothing anywhere saying why — the shape of
[`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md), on a
different axis. The refusal that reads like a decision is on the provider; the silence is on the
consumer.
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.
## Fixed
Needs fan out per consuming module in one place, after the resolution walk. The credential's key
gains the consuming module, the grant file is named after both halves of the consumer, needs are
matched by provision *and* module, and the provisioners name the role and the access key after the
module rather than the machine. The refusal is gone because there is nothing left to refuse.
Existing credentials are discarded rather than backfilled: they cannot say which module they were
for, and one is remade and delivered to both ends on the next push, so it costs one rotation.
A guard was added for PostgreSQL's 63-byte identifier limit, which truncates with a notice rather
than an error — two consumers whose role names agree that far would otherwise become one login,
which is this same fault at a length nobody would think to test.
What it did **not** fix is [`023`](../023-a-consumer-cannot-build-a-connection-string/00-report.md):
a consumer now receives its own password and still cannot build a connection string, because the
user name is the provisioner's invention and the bound values cannot reach a configuration file.