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.
114 lines
5.5 KiB
Markdown
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.
|