Files
hq/04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md
T
jschoubben 80f18caf03 022 fixed; 023 filed — a password is not a connection
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.
2026-09-01 02:40:40 +02:00

5.5 KiB

status, opened, located-in, fixed-by, amended-design
status opened located-in fixed-by amended-design
fixed 2026-09-01
mesh-control
mesh-control 0af3ea1

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, 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):

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. 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: 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.