diff --git a/examples/modules/gitea.json b/examples/modules/gitea.json index 963eda2..537ba43 100644 --- a/examples/modules/gitea.json +++ b/examples/modules/gitea.json @@ -23,7 +23,7 @@ {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, {"id": "server-env", "type": "file", "path": "/var/lib/gitea/server.env", "mode": "0600", - "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n"}, + "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=gitea\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"}, {"id": "server", "type": "container", "name": "gitea", "image": "gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", diff --git a/examples/modules/keycloak.json b/examples/modules/keycloak.json index 5b7af87..8bdd5ec 100644 --- a/examples/modules/keycloak.json +++ b/examples/modules/keycloak.json @@ -33,6 +33,9 @@ {"id": "admin-env", "type": "file", "path": "/var/lib/keycloak/admin.env", "mode": "0600", "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n"}, + {"id": "database-env", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/keycloak\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n"}, + {"id": "net", "type": "network", "name": "keycloak"}, {"id": "server", "type": "container", "name": "keycloak", @@ -40,9 +43,9 @@ "network": "keycloak", "args": ["start-dev"], "env": {"KC_DB": "postgres", "KC_HTTP_ENABLED": "true", "KC_HEALTH_ENABLED": "true"}, - "env-file": ["/var/lib/keycloak/admin.env"], + "env-file": ["/var/lib/keycloak/admin.env", "/var/lib/keycloak/database.env"], "ports": ["8080:8080"], - "restart-on": ["admin-env"]}, + "restart-on": ["admin-env", "database-env"]}, {"id": "provisioner", "type": "container", "name": "mesh-provision-keycloak", "image": "mesh-provision-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", diff --git a/examples/modules/modules_test.go b/examples/modules/modules_test.go index 5973cd4..e91c687 100644 --- a/examples/modules/modules_test.go +++ b/examples/modules/modules_test.go @@ -359,3 +359,63 @@ func secretsUsedForTest(content string) []string { } return used } + +// The real manifests, resolved together, produce a connection a program could use. +// +// **Parsing is not working, and this file has now learned that twice.** These modules parsed and +// resolved for a day while their credentials went into files nothing could read; they would have +// parsed and resolved just as happily with a connection string that named no user. What has to be +// true is that the bytes reaching the machine are usable, so that is what this asks. +func TestKeycloakGetsAConnectionAProgramCouldUse(t *testing.T) { + shelf := map[string]catalogue.Manifest{} + for _, name := range []string{"postgres.json", "keycloak.json"} { + m := read(t, name) + shelf[m.Module] = m + } + resolved, err := catalogue.Resolve(shelf, []string{"keycloak"}, + catalogue.Node{Name: "workstation", At: "workstation.internal", + Capabilities: map[string]bool{"container-runtime": true}}, + catalogue.World{Offered: map[string][]catalogue.Provider{ + "postgres-database": {{Node: "anchor", At: "anchor.internal", + Serves: map[string]any{"port": float64(5432)}}}, + }}) + if err != nil { + t.Fatalf("the real manifests do not resolve: %v", err) + } + for i := range resolved.Needs { + resolved.Needs[i].Sealed = "sealed" + } + out, err := resolved.Declaration(catalogue.Rendering{ + Needed: map[string]map[string]string{"keycloak": {"admin": "sealed-admin"}}, + }) + if err != nil { + t.Fatalf("the real manifests do not declare: %v", err) + } + + var env string + for _, r := range out { + if r["path"] == "/var/lib/keycloak/database.env" { + env, _ = r["content"].(string) + } + } + if env == "" { + t.Fatal("keycloak was given no database configuration at all") + } + // Every part of a connection, and nothing left unfilled. A leftover ${...} would be read as + // a value by whatever parses this. + for _, wanted := range []string{ + "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak", + "KC_DB_USERNAME=mesh_workstation_keycloak", + } { + if !strings.Contains(env, wanted) { + t.Errorf("the connection is missing %q:\n%s", wanted, env) + } + } + if strings.Contains(env, "${bound:") { + t.Errorf("a placeholder reached the machine as a value:\n%s", env) + } + // The password is the one hole that stays, because only the host may fill it. + if !strings.Contains(env, "KC_DB_PASSWORD=${secret:postgres-database}") { + t.Errorf("the password is not left for the host to fill:\n%s", env) + } +} diff --git a/examples/objectstore-provisioner/main.go b/examples/objectstore-provisioner/main.go index bad4567..5feb4c1 100644 --- a/examples/objectstore-provisioner/main.go +++ b/examples/objectstore-provisioner/main.go @@ -50,9 +50,13 @@ const alias = "store" // contribution is one consumer, as the mesh described it. type contribution struct { From string `json:"from"` - // Node is empty for a module on this machine, which is asking for something local and is not - // this provisioner's business. - Node string `json:"node"` + Node string `json:"node"` + // As is what to call the login this consumer will use. + // + // **Given, not invented** (novox/hq 04-ISSUES/023). This provisioner used to make the name + // itself, which worked and meant the consumer — the one thing that has to present it — could + // not learn it. The mesh derives it once and sends it to both ends. + As string `json:"as"` Secret string `json:"secret"` Values map[string]any `json:"values"` } @@ -195,7 +199,20 @@ func run(ctx context.Context) error { // One access key per consumer, and a consumer is a module on a machine (novox/hq // 04-ISSUES/022) — otherwise every service on a node shares one key, and the policy that // confines each to its own bucket confines none of them. - key := mark + c.Node + "_" + c.From + // + // The name comes from the mesh (04-ISSUES/023), so the consumer knows what it will be. + key := c.As + if key == "" { + return fmt.Errorf( + "%s on %s was granted a bucket and the mesh did not say what to call its key, "+ + "so there is no name both ends would agree on", c.From, c.Node) + } + if !strings.HasPrefix(key, mark) { + return fmt.Errorf( + "%s on %s is to be called %q, which does not begin with %q — this provisioner "+ + "removes what it made by that prefix, so it would never let this one go", + c.From, c.Node, key, mark) + } wanted[key] = true if err := ensureBucket(ctx, bucket); err != nil { return err diff --git a/examples/postgres-provisioner/main.go b/examples/postgres-provisioner/main.go index 52c48d8..ae8547e 100644 --- a/examples/postgres-provisioner/main.go +++ b/examples/postgres-provisioner/main.go @@ -46,9 +46,13 @@ const mark = "mesh_" // contribution is one consumer, as the mesh described it. type contribution struct { From string `json:"from"` - // Node is empty for a module on this machine, which is asking for something local and is not - // this provisioner's business. - Node string `json:"node"` + Node string `json:"node"` + // As is what to call the login this consumer will use. + // + // **Given, not invented** (novox/hq 04-ISSUES/023). This provisioner used to make the name + // itself, which worked and meant the consumer — the one thing that has to present it — could + // not learn it. The mesh derives it once and sends it to both ends. + As string `json:"as"` Secret string `json:"secret"` Values map[string]any `json:"values"` } @@ -201,12 +205,27 @@ func run(ctx context.Context) error { return fmt.Errorf("%s's credential should be at %s and is not there", c.Node, c.Secret) } - // **Named after the module and the machine, not the machine** (novox/hq 04-ISSUES/022). - // A node routinely runs several services against one database server, and one role for - // all of them means gitea's login opens keycloak's data — created exactly as asked, with - // nothing anywhere to say so. It also makes withdrawal impossible: one role cannot be - // removed for one consumer while another still holds it. - role := mark + c.Node + "_" + c.From + // **The name the mesh gave this consumer** — a module on a machine (novox/hq + // 04-ISSUES/022), said by the mesh rather than derived here (04-ISSUES/023). + // + // Refused rather than derived when it is absent. Falling back to a name of this + // provisioner's own would work, and the consumer would be told a different one and fail + // to authenticate against a role that exists — which is the worst of the three outcomes, + // because everything reports success. + role := c.As + if role == "" { + return fmt.Errorf( + "%s on %s was granted a database and the mesh did not say what to call its "+ + "login, so there is no name both ends would agree on", c.From, c.Node) + } + if !strings.HasPrefix(role, mark) { + // Withdrawal finds this provisioner's work by prefix. A login without it would never + // be removed, and would keep working for ever after its consumer went away. + return fmt.Errorf( + "%s on %s is to be called %q, which does not begin with %q — this provisioner "+ + "removes what it made by that prefix, so it would never let this one go", + c.From, c.Node, role, mark) + } wanted[role] = true if err := ensureRole(ctx, db, role, strings.TrimSpace(string(password))); err != nil { return err diff --git a/examples/postgres-provisioner/where_test.go b/examples/postgres-provisioner/where_test.go index 9c0cf6e..534afae 100644 --- a/examples/postgres-provisioner/where_test.go +++ b/examples/postgres-provisioner/where_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/novox/mesh-control/internal/catalogue" ) // The password comes from a file, because that is how the mesh delivers one. @@ -83,3 +85,25 @@ func TestARoleNameTooLongToBeDistinctIsRefused(t *testing.T) { t.Errorf("the refusal does not say what goes wrong: %v", err) } } + +// The prefix this provisioner removes by is the prefix the mesh names by. +// +// **Two definitions on purpose.** A provisioner is a separate program and anyone may write one, so +// the prefix is part of the contract rather than a symbol to import — the same reason the grant +// file's shape is written down rather than shared. But a contract with two copies and no check is +// a contract until somebody edits one: if the mesh named `nox_` and this removed `mesh_`, every +// login it created would be permanent, and nothing would report anything at all. +func TestTheMarkAgreesWithWhatTheMeshNamesBy(t *testing.T) { + if mark != catalogue.IdentityPrefix { + t.Fatalf( + "this provisioner removes what begins with %q and the mesh names things %q, so it "+ + "would never remove anything it made", mark, catalogue.IdentityPrefix) + } +} + +// And a name the mesh would produce is one this provisioner accepts. +func TestWhatTheMeshNamesIsUsableAsARole(t *testing.T) { + if err := usableRole(catalogue.ConsumerIdentity("home-server", "keycloak")); err != nil { + t.Fatalf("the mesh named a consumer and this cannot make a role for it: %v", err) + } +} diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go new file mode 100644 index 0000000..7bcd674 --- /dev/null +++ b/internal/catalogue/bound_into_files.go @@ -0,0 +1,150 @@ +package catalogue + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// The half of a connection that is not secret, put where the program reading it can find it. +// +// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can +// be placed inside any configuration file a module writes: the module leaves a hole, the mesh +// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in. +// The host and the port and the name to present are ordinary facts the mesh holds in the clear, +// and they were the ones stuck: readable only inside a JSON binding, which a program reading +// `KEY=value` cannot use. +// +// So the same shape, and simpler. These values are not secret, so **the control plane substitutes +// them itself** before the declaration is sent. Nothing new reaches the host, which learns no +// formats and gains no fields. +// +// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is: +// `at`, `as` and `from` are facts about any provision at all, and everything else comes from what +// the provider said it serves — whose keys are agreed by the requirement's name, not by this file. + +// bound is where a module says a value from one of its bindings belongs: +// ${bound:.}. +var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)\}`) + +// boundUsed are the (provision, key) pairs a file's content asks for, first appearance first. +func boundUsed(content string) [][2]string { + var used [][2]string + seen := map[string]bool{} + for _, m := range bound.FindAllStringSubmatch(content, -1) { + if key := m[1] + ":" + m[2]; !seen[key] { + seen[key] = true + used = append(used, [2]string{m[1], m[2]}) + } + } + return used +} + +// knownFor is everything a module may name from one of its bindings. +// +// Three facts the mesh states about any provision, plus whatever the provider said it serves. A +// module may not reach a binding it does not have — the same boundary as a secret, for the same +// reason. +func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string { + out := map[string]map[string]string{} + for _, want := range m.Wants() { + for i := range needs { + n := needs[i] + if n.Name != want || n.For != m.Module { + continue + } + values := map[string]string{ + "at": n.At, + "from": n.From, + "as": ConsumerIdentity(node, m.Module), + } + for key, value := range n.Serves { + // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, + // which is what a float would write and what a connection string would refuse. + values[key] = plainly(value) + } + out[want] = values + } + } + return out +} + +// plainly renders a served value as a program would expect to read it. +func plainly(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") + case bool: + return fmt.Sprintf("%t", v) + case nil: + return "" + default: + return fmt.Sprint(v) + } +} + +// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows. +// +// A placeholder naming something the module does not require, or a key the provider does not +// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a +// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing +// somewhere that names neither the module nor the mesh. +func boundInto(resource map[string]any, known map[string]map[string]string, module string) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok { + return nil + } + for _, pair := range boundUsed(content) { + provision, key := pair[0], pair[1] + values, has := known[provision] + if !has { + return fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s", + module, provision, key, module, provision, orNothing(namesOfBindings(known))) + } + value, said := values[key] + if !said { + return fmt.Errorf( + "%s asks its %s binding for %q, and what answers it says %s", + module, provision, key, orNothing(namesOfKeys(values))) + } + resource["content"] = strings.ReplaceAll( + content, fmt.Sprintf("${bound:%s:%s}", provision, key), value) + content = resource["content"].(string) + } + return nil +} + +func namesOfBindings(known map[string]map[string]string) []string { + var names []string + for name := range known { + names = append(names, fmt.Sprintf("%q", name)) + } + sort.Strings(names) + return names +} + +func namesOfKeys(values map[string]string) []string { + var names []string + for key := range values { + names = append(names, fmt.Sprintf("%q", key)) + } + sort.Strings(names) + return names +} + +func orNothing(names []string) string { + if len(names) == 0 { + return "nothing" + } + return join(names) +} diff --git a/internal/catalogue/bound_into_files_test.go b/internal/catalogue/bound_into_files_test.go new file mode 100644 index 0000000..278cc6c --- /dev/null +++ b/internal/catalogue/bound_into_files_test.go @@ -0,0 +1,170 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A consumer writes its own connection string (novox/hq 04-ISSUES/023). +// +// Everything needed for one now reaches the file that needs it: the address and port from what the +// provider serves, the name to present from what the mesh decided, the database name from what the +// module itself contributed, and the password through the sealed hole the host fills. +func TestAConsumerCanWriteAConnectionString(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n" + + "KC_DB_USERNAME=${bound:postgres-database:as}\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n", + }}, + }}, + Needs: []Needed{{ + Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak", + Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)}, + }}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + if file == nil { + t.Fatalf("no file in %v", out) + } + got := file["content"].(string) + + want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" + + "KC_DB_USERNAME=mesh_workstation_keycloak\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n" + if got != want { + t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want) + } + // The password is still a hole. Only the host may fill that one, and only on the machine. + if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" { + t.Errorf("the credential did not travel with the file: %v", file) + } +} + +// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what +// every connection string in the world would refuse. +func TestAPortIsWrittenAsAPort(t *testing.T) { + if got := plainly(float64(5432)); got != "5432" { + t.Errorf("a port was rendered as %q", got) + } + if got := plainly("postgres"); got != "postgres" { + t.Errorf("a string was rendered as %q", got) + } +} + +// The name the provider will create is the name the consumer is told to present. One derivation, +// so the two agree by construction rather than by two conventions written on the same day. +func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) { + provider, err := Resolve(shelf(Manifest{ + Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"), + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + }), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := provider.Declaration(Rendering{Grants: []Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "keycloak", + Values: map[string]any{"name": "keycloak"}, Sealed: "sealed", + }}}) + if err != nil { + t.Fatal(err) + } + var told string + for _, res := range out { + if res["path"] == "/var/lib/postgres/grants/mesh.json" { + told = res["content"].(string) + } + } + if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) { + t.Errorf("the provider was not told what to call the login: %s", told) + } + if told == "" { + t.Fatal("the provider was told nothing at all") + } + // Which is exactly what the consumer, on its own machine, is told to present. + if ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" { + t.Errorf("got %q", ConsumerIdentity("workstation", "keycloak")) + } +} + +// A binding the module does not have is a typo, refused where it was written. +func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "host=${bound:s3-bucket:at}\n", + }}, + }}, + Needs: []Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module read a binding it does not have") + } + if !strings.Contains(err.Error(), "postgres-database") { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// A key the provider does not serve is refused too. Written through, the literal placeholder +// becomes a hostname, and the failure names neither the module nor the mesh. +func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "sock=${bound:postgres-database:socket}\n", + }}, + }}, + Needs: []Needed{{ + Name: "postgres-database", From: "anchor", At: "a", For: "keycloak", + Serves: map[string]any{"port": float64(5432)}, + }}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module read a key nothing serves, and the placeholder became a value") + } + if !strings.Contains(err.Error(), `"port"`) { + t.Errorf("the refusal does not say what is served: %v", err) + } +} + +// A name too long to stay distinct is refused before anything is created. +func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) { + if err := CheckIdentity("anchor", "keycloak"); err != nil { + t.Fatalf("an ordinary consumer was refused: %v", err) + } + if err := CheckIdentity(strings.Repeat("n", 40), strings.Repeat("m", 40)); err == nil { + t.Fatal("an identity that providers would shorten was accepted") + } +} + +// Two modules on one node get two identities, which is the whole point of both issues. +func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) { + if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("anchor", "keycloak") { + t.Fatal("two consumers on one machine share an identity") + } + // And one module on two nodes, likewise. + if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("laptop", "gitea") { + t.Fatal("one module on two machines shares an identity") + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 6e32638..e2fcdfe 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -263,7 +263,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } found = here } - file, err := boundFile(*found, m.Binds[to]) + file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, m.Module)) if err != nil { return nil, err } @@ -317,6 +317,8 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { if err != nil { return nil, err } + // And what its bindings say, for the half of a connection that is not secret. + known := knownFor(m, r.Needs, r.Node) for _, unsettled := range resources { resource, err := ApplySettings(unsettled, with.Settings[m.Module]) @@ -334,6 +336,12 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { if err := intoFile(copied, sealed, m.Module); err != nil { return nil, err } + // **Substituted here, not on the machine.** A bound value is not secret — the mesh + // holds it in the clear — so there is nothing for the host to be the only witness of, + // and sending it already filled in means the host learns no new field. + if err := boundInto(copied, known, m.Module); err != nil { + return nil, err + } copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) // A service saying what it reflects names resources within its own module, so those // are prefixed too or they would point at nothing. @@ -382,6 +390,13 @@ type Contribution struct { // provider that reaches back to a consumer would have to know how the mesh names machines, // which is a convention leaking into every module that implements a provision. At string `json:"at,omitempty"` + // As is who this consumer is: what the provider should call the login it creates. + // + // **Said by the mesh rather than invented by the provisioner** (novox/hq 04-ISSUES/023). It + // used to be neither — the provisioner made a name, and the consumer, which has to present it + // to authenticate, had no way to learn it. One derivation reaches both ends, so they agree by + // construction. + As string `json:"as"` // Secret is the file on this machine holding that consumer's credential, sealed to it. // // Named rather than carried, for the same reason the private network's key is: the mesh @@ -438,6 +453,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), Secret: grantPath(directories[g.Provision], g.Consumer, g.From), }) } @@ -499,7 +515,7 @@ func sortedKeys[V any](m map[string]V) []string { // Where it is and what the providing module said about using it. **No credential**, and the file // says so rather than leaving a reader to wonder whether one was meant to be there — a missing // field looks like a bug, and a stated absence looks like a boundary. -func boundFile(n Needed, path string) (map[string]any, error) { +func boundFile(n Needed, path, as string) (map[string]any, error) { // A record has no machine and no address. Saying so is the difference between a reader // concluding "somewhere with no address" and concluding the mesh failed to fill something in. where := any(n.At) @@ -511,7 +527,11 @@ func boundFile(n Needed, path string) (map[string]any, error) { "provision": n.Name, "from": n.From, "at": where, - "serves": n.Serves, + // Who this module is at the other end. **The half that was missing** (novox/hq + // 04-ISSUES/023): a consumer was told the address, the port and where its password is, + // and not the name it must present — which the provisioner had invented. + "as": as, + "serves": n.Serves, // **Where the credential is, not what it is.** It stopped being true that the mesh // cannot issue one when 021 was fixed, and a comment asserting a fact about the mesh that // has become false is worse than none — somebody reads it and stops looking. diff --git a/internal/catalogue/identity.go b/internal/catalogue/identity.go new file mode 100644 index 0000000..dd93ac8 --- /dev/null +++ b/internal/catalogue/identity.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "fmt" + "regexp" + "strings" +) + +// Who a consumer is, said once by the mesh (novox/hq 04-ISSUES/023). +// +// **The provisioner used to invent this and nothing else could derive it.** It made a role called +// `mesh__`, which is a reasonable name and is knowable nowhere else: not by the +// control plane, not by the binding, and above all not by the consumer — which has to present it +// in order to authenticate. The one identifier needed to connect was the one thing no part of the +// mesh would say. +// +// So the mesh says it. It goes to the provider in the grant and to the consumer in its binding, +// from **one derivation**, which is what makes the two ends agree by construction rather than by +// two conventions that were the same on the day they were written. +// +// **It is still name-agnostic.** The mesh does not know what a role or an access key or a client +// is; it says who is asking, and each provisioner makes that true in whatever its own system +// calls an identity. What a provider does with it is the provider's business, as everything about +// a provision is. + +// identityUnusable is every character that is not safe unquoted in the systems these names reach. +// +// Conservative on purpose: lower-case letters, digits and underscore reach a PostgreSQL role, a +// MinIO access key, an LDAP uid and a Keycloak client without quoting or escaping in any of them. +// A wider set would work in most and fail in one, discovered as a login that cannot be created. +var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) + +// IdentityPrefix marks what the mesh made, so a provisioner can find its own work and leave +// everything else alone. Withdrawal depends on it entirely. +const IdentityPrefix = "mesh_" + +// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. +// +// 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. +func ConsumerIdentity(node, module string) string { + clean := func(s string) string { + return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") + } + 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 + +// CheckIdentity refuses a name a provider would silently shorten. +// +// **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. +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", + module, node, got, len(got), identityLimit) +}