broker: a module account scopes its tool serve queues + mesh.rpc (ADR 0052) #5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user