broker: a module account scopes its tool serve queues + mesh.rpc (ADR 0052) #5

Merged
jschoubben merged 8 commits from events/tool-account-scope into initialization 2026-09-05 01:06:52 +00:00
7 changed files with 120 additions and 17 deletions
Showing only changes of commit 9b7ba2e20c - Show all commits
+17 -1
View File
@@ -520,9 +520,25 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
// working for ever after its consumer went away.
from = ""
}
// The consumer's identity slug, from its own manifest, carried on the grant so the provider
// derives the same login the consumer does (novox/hq ADR 0054). Refused here if it still would
// not fit the tightest backend — the mesh chose the name, so the mesh refuses it, with the
// remedy a short slug rather than a login a provider silently shortened.
slug := ""
for _, mm := range plan.Modules {
if mm.Module == s.ConsumerModule {
slug = mm.Slug
break
}
}
if from != "" {
if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil {
return nil, err
}
}
out = append(out, catalogue.Grant{
Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer],
From: from, Values: values, Sealed: s.ForProvider})
From: from, Values: values, Slug: slug, Sealed: s.ForProvider})
}
return out, nil
}
+1 -1
View File
@@ -57,7 +57,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
values := map[string]string{
"at": n.At,
"from": n.From,
"as": ConsumerIdentity(node, m.Module),
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
}
for key, value := range n.Serves {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
+6 -2
View File
@@ -59,6 +59,10 @@ type Grant struct {
// Values are what that module contributed — the name it wants, and anything else the
// provision's own vocabulary defines.
Values map[string]any
// Slug is the consumer module's identity slug, if it declared one — carried on the grant so the
// provider side derives the same login the consumer does, even across nodes where the consumer's
// manifest is not in view (novox/hq ADR 0054). Empty means "use the module name".
Slug string
// At is where the consuming machine is on the private network, empty if it is not on one.
//
// Passed in with the grant because it is a fact about another machine, and resolution answers
@@ -293,7 +297,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
}
found = here
}
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, m.Module))
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
if err != nil {
return nil, err
}
@@ -490,7 +494,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
}
out[g.Provision] = append(out[g.Provision], Contribution{
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
As: ConsumerIdentity(g.Consumer, g.From),
As: ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)),
Secret: grantPath(directories[g.Provision], g.Consumer, g.From),
})
}
+25 -12
View File
@@ -34,7 +34,18 @@ var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
// everything else alone. Withdrawal depends on it entirely.
const IdentityPrefix = "mesh_"
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates.
// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it
// has declared one, otherwise its name (novox/hq ADR 0054). A module with a name short enough to fit
// the tightest backend needs no slug; one whose name would overflow declares a short legible one.
func IdentitySource(slug, name string) string {
if slug != "" {
return slug
}
return name
}
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The
// `module` argument is the identity source — a slug or a name; see IdentitySource.
//
// A dot and a dash both become an underscore, so `home-server` and `home.server` would collide —
// which cannot happen, because a machine has one name and it is either.
@@ -45,24 +56,26 @@ func ConsumerIdentity(node, module string) string {
return IdentityPrefix + clean(node) + "_" + clean(module)
}
// identityLimit is the shortest identifier limit among the systems these names reach:
// PostgreSQL's NAMEDATALEN - 1.
const identityLimit = 63
// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access
// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds —
// the comment used to name PostgreSQL and was wrong. A name over it is refused, with the remedy a
// short slug (ADR 0054), not silently cut to fit.
const identityLimit = 20
// CheckIdentity refuses a name a provider would silently shorten.
// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches.
//
// **Truncation is not an error in PostgreSQL** — a name past the limit is cut to fit and the
// statement succeeds. Two consumers agreeing for the first 63 bytes would become one login, which
// is 022 again at a length nobody would think to test. Refused here rather than in each
// provisioner, because the mesh chose the name and is the only thing that can choose another.
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the
// name and is the only thing that can choose another. The remedy is a first-class one: give the
// module a short `slug` (ADR 0054), or shorten the machine's name.
func CheckIdentity(node, module string) error {
got := ConsumerIdentity(node, module)
if len(got) <= identityLimit {
return nil
}
return fmt.Errorf(
"%s on %s would be identified as %q, which is %d characters and some providers keep %d — "+
"another consumer shortened to the same name would share its login. Shorten the "+
"machine's name or the module's",
"%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+
"give the module a shorter `slug` or shorten the machine's name",
module, node, got, len(got), identityLimit)
}
+53
View File
@@ -0,0 +1,53 @@
package catalogue
import "testing"
// Each test names the decision it defends (novox/hq ADR 0017).
func TestASlugIsPreferredOverTheModuleName(t *testing.T) {
// A module that declared a slug is identified by it, so a long name can be made to fit the
// tightest backend without a hash (novox/hq ADR 0054).
if got := IdentitySource("kc", "keycloak"); got != "kc" {
t.Errorf("the slug was not preferred: %q", got)
}
if got := IdentitySource("", "redis"); got != "redis" {
t.Errorf("without a slug, the name should be used: %q", got)
}
if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" {
t.Error("a slug did not shape the identity")
}
}
func TestCheckIdentityFitsTheTightestBackend(t *testing.T) {
// 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak
// is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug.
if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20
t.Errorf("a 20-character identity was refused: %v", err)
}
if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22
t.Error("an over-long identity was accepted")
}
// But with a slug it fits, and is accepted.
if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil {
t.Errorf("a slugged identity that fits was refused: %v", err)
}
}
func TestTheRefusalNamesTheRemedy(t *testing.T) {
err := CheckIdentity("anchor", "bucketuser")
if err == nil {
t.Fatal("expected a refusal")
}
if !contains(err.Error(), "slug") {
t.Errorf("the refusal did not point at the slug as the remedy: %v", err)
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+14
View File
@@ -119,6 +119,13 @@ type Manifest struct {
Module string `json:"module"`
Version string `json:"version,omitempty"`
// Slug is a short identifier the mesh uses in place of the module name when it derives a
// consumer's login (novox/hq ADR 0054). Optional: a module with a short name needs none. It
// exists because `mesh_<node>_<module>` must fit the tightest backend a consumer reaches — an S3
// access key is 20 characters — and a long module name would overflow it. A person choosing
// `kc` for keycloak keeps the identity legible where a hash would not.
Slug string `json:"slug,omitempty"`
// Provides are the names other modules may require. A module always provides its own name;
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
Provides []Offer `json:"provides,omitempty"`
@@ -467,6 +474,13 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
}
// A slug is a short identifier the mesh derives a login from (novox/hq ADR 0054). The same
// charset as a name; its length is checked against a backend's limit at assignment, where the
// node it joins is known — a slug that is fine on one machine's short name can overflow another's.
if m.Slug != "" && !name.MatchString(m.Slug) {
problems = append(problems, fmt.Sprintf(
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
}
for _, offer := range m.Provides {
p := offer.Name
if !name.MatchString(p) {
+4 -1
View File
@@ -54,7 +54,10 @@ func Make(consumerKey, providerKey string) (Sealed, error) {
return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made")
}
value := make([]byte, 32)
// 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an
// S3 access key accepts (8–40), the tightest of the backends a minted password reaches — the same
// "fit the tightest backend" rule ADR 0054 sets for the login, on the secret. 240 bits is ample.
value := make([]byte, 30)
if _, err := rand.Read(value); err != nil {
return Sealed{}, err
}