identity: a consumer's identity fits the tightest backend, via a slug (ADR 0054)
A module may declare a short `slug`; the mesh derives mesh_<node>_<slug|name> and refuses at assignment (naming the slug as the remedy) when it would still overflow — identityLimit is now 20, an S3 access key's, the tightest of the backends a login reaches (04-ISSUES/010). The slug rides the grant so the provider derives the same login the consumer does, even across nodes. CheckIdentity is now wired, in grantsFor. Also, the minted secret shrinks to 40 chars (30 bytes) from 43: an S3 secret key is 8-40, the same fit-the-tightest-backend rule on the credential's other half. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -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