022 turned out to have a silent half worth recording: the provider refuses loudly and names the modules, which reads as a decision, while the consuming node does not refuse at all. Three modules wanting one database produce one need, so two of them get no credential file and each starts and fails to authenticate with nothing saying why. 023 is what remained after fixing it. A consumer now gets its own password, in whatever shape its configuration wants, and still cannot connect: the user name is invented by the provisioner and recorded nowhere in the mesh, and the host and port sit in a JSON binding that an application reading KEY=value cannot use. The asymmetry is backwards and the coverage document now says so. The secret is the hard case, because the mesh must not be able to read it, and the secret is the part that arrives. The host and port are ordinary facts the mesh holds in the clear, and they are the ones stuck. Keycloak, Gitea, Mailu and MinIO all parse and resolve and none of them can start. This is what stands between the module set and a running one.
114 lines
5.5 KiB
Markdown
114 lines
5.5 KiB
Markdown
---
|
|
status: fixed
|
|
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.
|