diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go new file mode 100644 index 0000000..a23525b --- /dev/null +++ b/cmd/mesh-control/licence.go @@ -0,0 +1,235 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" +) + +// licenceCommand is everything about model access the mesh holds. +// +// **A licence is a named thing and the name is the operator's** (novox/hq ADR 0024). *The personal +// account*, *the organisation's account* — those are names a person uses, and the mesh has to use +// them too, because the whole point is saying which one a given consumer uses. +func licenceCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("licence add|list|use|release|key|forget") + } + switch args[0] { + case "add": + return licenceAdd(ctx, args[1:]) + case "list": + return licenceList(ctx) + case "use": + return licenceUse(ctx, args[1:], true) + case "release": + return licenceUse(ctx, args[1:], false) + case "key": + return licenceKey(ctx, args[1:]) + case "forget": + return licenceForget(ctx, args[1:]) + } + return fmt.Errorf("licence %q; it is add, list, use, release, key or forget", args[0]) +} + +func licenceAdd(ctx context.Context, args []string) error { + set := flag.NewFlagSet("licence add", flag.ContinueOnError) + // What a consumer must know that is not secret — a base URL, a model name. Never the key. + serves := set.String("serves", "", + "JSON a consumer must know that is not secret, such as a base URL or a model") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 2 { + return errors.New(`licence add [--serves '{"model":"..."}']`) + } + provider, name := positionals[0], positionals[1] + + values := map[string]any{} + if strings.TrimSpace(*serves) != "" { + if err := json.Unmarshal([]byte(*serves), &values); err != nil { + return fmt.Errorf("--serves is not JSON: %w", err) + } + } + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.Add(ctx, name, provider, values); err != nil { + return err + } + fmt.Printf("%s (%s) recorded. Nothing uses it yet, and it has no key:\n"+ + " licence use %s \n licence key %s\n", name, provider, name, name) + return nil +} + +func licenceList(ctx context.Context) error { + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + all, err := held.All(ctx) + if err != nil { + return err + } + if len(all) == 0 { + // Said, not printed as nothing: an empty list and a failed read must never look the same. + fmt.Println("this mesh holds no licences") + return nil + } + for _, one := range all { + holders, err := held.HoldersOf(ctx, one.Name) + if err != nil { + return err + } + fmt.Printf("%s (%s)\n", one.Name, one.Provider) + if len(holders) == 0 { + fmt.Printf(" nobody uses it\n") + } + for _, h := range holders { + // Whether it has a key is the question somebody is actually asking, so it is said + // per holder rather than per licence: the key was sealed to the holders that existed + // when it was supplied, and one recorded afterwards has none. + state := "has no key — supply it again with `licence key " + one.Name + "`" + if h.Sealed != "" { + state = "has a key" + } + fmt.Printf(" %s on %s: %s\n", h.Module, h.Node, state) + } + } + return nil +} + +func licenceUse(ctx context.Context, args []string, using bool) error { + verb := "use" + if !using { + verb = "release" + } + if len(args) != 3 { + return fmt.Errorf("licence %s ", verb) + } + name, node, module := args[0], args[1], args[2] + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + if !using { + if err := held.StopUsing(ctx, name, node, module); err != nil { + return err + } + fmt.Printf("%s on %s no longer uses %s. Its copy of the key goes on the next push\n", + module, node, name) + return nil + } + if err := held.Use(ctx, name, node, module); err != nil { + return err + } + fmt.Printf("%s on %s uses %s.\n", module, node, name) + // The consequence, said now rather than discovered as a machine that resolves and receives + // nothing: the mesh discarded the plaintext, so a holder added after the key was supplied has + // no key and the mesh cannot make one. + sealed, err := held.KeyFor(ctx, name, node, module) + if err != nil { + return err + } + if sealed == "" { + fmt.Printf(" It has no key yet — the mesh discarded the plaintext when it was supplied "+ + "and cannot seal another. Supply it again:\n licence key %s\n", name) + } + return nil +} + +// licenceKey is the *accept* verb novox/hq ADR 0024 names as missing. +// +// Take a value, seal it to each holder, and discard the plaintext. Every other credential the +// mesh handles it generated itself; an API key arrives from a person, and a mesh that kept +// operator-supplied keys readably is the arrangement this project measured and rejected. +func licenceKey(ctx context.Context, args []string) error { + set := flag.NewFlagSet("licence key", flag.ContinueOnError) + // A file rather than an argument, by default. A key on a command line is a key in shell + // history and in every process listing taken while it ran. + from := set.String("file", "", "read the key from a file instead of standard input") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("licence key [--file ]") + } + name := positionals[0] + + var value string + if *from != "" { + raw, err := os.ReadFile(*from) + if err != nil { + return err + } + value = strings.TrimSpace(string(raw)) + } else { + fmt.Fprintln(os.Stderr, "reading the key from standard input; it is not echoed anywhere") + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("nothing was given on standard input: %w", err) + } + value = strings.TrimSpace(line) + } + + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + sealed, err := held.Accept(ctx, name, value, func(node string) (string, error) { + return inv.SealingKeyOf(ctx, node) + }) + if err != nil { + return err + } + // Not echoed back, ever. What is stored is unreadable by whoever holds it, the mesh included, + // and printing the value here would put the one copy that matters on a terminal. + fmt.Printf("sealed to %d holder(s). The mesh has discarded the key and cannot read it back\n", + sealed) + fmt.Printf(" run `push` to deliver it\n") + return nil +} + +func licenceForget(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("licence forget ") + } + held, err := openLicences(ctx) + if err != nil { + return err + } + defer held.Close() + if err := held.Forget(ctx, args[0]); err != nil { + return err + } + // Said plainly, because the mesh cannot do it and pretending otherwise is worse than useless: + // a licence outliving its holder is a live credential nobody is watching. + fmt.Printf("%s is forgotten, and every record of who held it with it.\n"+ + " The key itself is not the mesh's to revoke — do that where the licence was bought\n", + args[0]) + return nil +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 9abe1e1..08d1117 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -25,6 +25,7 @@ import ( "github.com/novox/mesh-control/internal/catalogue" "github.com/novox/mesh-control/internal/identity" "github.com/novox/mesh-control/internal/inventory" + "github.com/novox/mesh-control/internal/licences" "github.com/novox/mesh-control/internal/link" "github.com/novox/mesh-control/internal/overlay" "github.com/novox/mesh-control/internal/store" @@ -45,6 +46,7 @@ var held = []struct { }{ {inventory.Name, inventory.Migrations}, {identity.Name, identity.Migrations}, + {licences.Name, licences.Migrations}, } func main() { @@ -69,6 +71,8 @@ func run() error { return buildCommand(ctx, args[1:]) case "builder": return builderCommand(ctx, args[1:]) + case "licence": + return licenceCommand(ctx, args[1:]) case "rotate": return rotateCommand(ctx, args[1:]) case "builds": @@ -144,6 +148,7 @@ func usage() { build [--ref R] have a build machine build it, and record what came out builds [] what has been built lately, and what came of it builder issue a broker account for a build machine, scoped to build work + licence add|list|use|key model access, under the name a person calls it rotate [--consumer ] a new credential for every holder, both ends at once pin which node this one gets a provision from unpin put that question back @@ -1048,6 +1053,15 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca if err != nil { return catalogue.Resolution{}, nil, err } + + // What this mesh can answer with a record rather than a machine, and which record each of + // this node's modules was put on. Read across a context boundary by name, which is what + // crossing one is allowed to carry (novox/hq ADR 0008). + world.Licences, world.Using, err = licencesFor(ctx, nodeName) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, At: onNetwork[nodeName]}, world) @@ -1059,6 +1073,17 @@ func planFor(ctx context.Context, inv *inventory.Inventory, nodeName string) (ca // password a provider is told to create is the one its consumer was given — and sealed to // this node before it was ever written down, so nothing between here and there can read it. for i, n := range resolved.Needs { + if n.ByRecord { + // Answered by something the mesh holds, so there is no pair-wise secret between two + // machines. Its key was supplied by a person and sealed to this node then; the mesh + // discarded the plaintext and cannot make another. + sealed, err := keyFor(ctx, n.From, nodeName, n.For) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved.Needs[i].Sealed = sealed + continue + } secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.From) if err != nil { // Said rather than skipped. A machine that resolves cleanly and receives no @@ -2315,3 +2340,79 @@ func builderCommand(ctx context.Context, args []string) error { fmt.Println("This is the only time it is shown.") return nil } + +// openLicences connects to the context that holds which model access exists and who may use it. +func openLicences(ctx context.Context) (*licences.Licences, error) { + held, err := licences.Open(ctx) + if err != nil { + return nil, err + } + if err := held.Ready(ctx, 30*time.Second); err != nil { + held.Close() + return nil, err + } + return held, nil +} + +// licencesFor is what this node can be answered with by record, and what it was put on. +// +// A mesh with no licences at all is the ordinary case and must not be an error: every existing +// mesh is one, and a control plane that refused to plan because nobody had bought an API key +// would be unusable for the thing it already does. +func licencesFor(ctx context.Context, node string) ( + map[string][]catalogue.Record, map[string]map[string]catalogue.Record, error) { + + held, err := openLicences(ctx) + if err != nil { + return nil, nil, err + } + defer held.Close() + + all, err := held.All(ctx) + if err != nil { + return nil, nil, err + } + if len(all) == 0 { + return nil, nil, nil + } + + offered := map[string][]catalogue.Record{} + byName := map[string]catalogue.Record{} + for _, one := range all { + record := catalogue.Record{Name: one.Name, Serves: one.Serves} + offered[licences.Provision] = append(offered[licences.Provision], record) + byName[one.Name] = record + } + + using := map[string]map[string]catalogue.Record{} + for _, one := range all { + holders, err := held.HoldersOf(ctx, one.Name) + if err != nil { + return nil, nil, err + } + for _, h := range holders { + if h.Node != node { + continue + } + if using[h.Module] == nil { + using[h.Module] = map[string]catalogue.Record{} + } + using[h.Module][licences.Provision] = byName[one.Name] + } + } + return offered, using, nil +} + +// keyFor is the licence key sealed to one machine, for one module. +// +// **Empty is not an error here.** The mesh discarded the plaintext when it was supplied, so a +// holder recorded afterwards genuinely has no key — and the declaration refuses that by name, +// where the module and the path are both in view, rather than here. +func keyFor(ctx context.Context, licence, node, module string) (string, error) { + held, err := openLicences(ctx) + if err != nil { + return "", err + } + defer held.Close() + return held.KeyFor(ctx, licence, node, module) +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 1331b6d..e131f15 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -157,6 +157,17 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { found = &r.Needs[i] } } + if found != nil && found.ByRecord && found.Sealed == "" { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } if found == nil || found.Sealed == "" { // Answered on this machine, or answered by a node the mesh could not seal to. // Nothing to write either way, and writing an empty credential file would be @@ -408,11 +419,17 @@ func sortedKeys[V any](m map[string]V) []string { // 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) { + // 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) + if n.ByRecord { + where = "a record in this mesh, not a machine" + } body, err := json.MarshalIndent(map[string]any{ "binding": 1, "provision": n.Name, "from": n.From, - "at": n.At, + "at": where, "serves": n.Serves, "generated": "by the mesh — do not edit; replaced whenever this changes. " + "It carries no credential: the mesh has no way to issue one yet", diff --git a/internal/catalogue/licence_test.go b/internal/catalogue/licence_test.go new file mode 100644 index 0000000..c0c33bc --- /dev/null +++ b/internal/catalogue/licence_test.go @@ -0,0 +1,150 @@ +package catalogue + +import ( + "strings" + "testing" +) + +func aModelUser() Manifest { + return Manifest{Module: "assistant", Requires: []string{"model-access"}, + Binds: map[string]string{"model-access": "/etc/assistant/model.json"}, + Secrets: map[string]string{"model-access": "/etc/assistant/key"}} +} + +// A provision answered by a record rather than a node. +// +// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public +// internet, so the rule that refuses two ends sharing no private network must not apply. This +// node is deliberately not on the private network at all — under the old rule that alone would +// refuse it. +func TestAProvisionAnsweredByARecordDoesNotNeedAPrivateNetwork(t *testing.T) { + got, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{ + Licences: map[string][]Record{"model-access": {{Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + Using: map[string]map[string]Record{"assistant": {"model-access": {Name: "personal", + Serves: map[string]any{"model": "a-model"}}}}, + }) + if err != nil { + t.Fatalf("a machine off the private network could not be given model access: %v", err) + } + if len(got.Needs) != 1 { + t.Fatalf("the licence was not recorded as something this node takes: %+v", got.Needs) + } + if !got.Needs[0].ByRecord { + t.Fatal("the licence was treated as a machine, so the reachability rule would apply to it") + } + if got.Needs[0].From != "personal" { + t.Fatalf("the licence is not named by what a person calls it: %+v", got.Needs[0]) + } +} + +// Refused when the consumer has not said which — and the refusal names the candidates and the +// command, because ADR 0024 warns this will be felt: a mesh holding three ways to reach a model +// refuses every consumer that has not chosen. +func TestAConsumerThatHasNotSaidWhichLicenceIsRefusedWithTheCandidates(t *testing.T) { + _, err := Resolve( + map[string]Manifest{"assistant": aModelUser()}, + []string{"assistant"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": { + {Name: "personal"}, {Name: "the-organisation"}, + }}}) + if err == nil { + t.Fatal("a consumer was given model access without anybody saying which") + } + said := err.Error() + for _, want := range []string{"personal", "the-organisation", "licence use"} { + if !strings.Contains(said, want) { + t.Fatalf("the refusal does not name %q, so it is correct and unusable:\n%s", want, said) + } + } +} + +// A model the mesh runs itself answers it locally, and a record is not consulted. +func TestAModelInTheMeshsOwnSetAnswersItWithoutALicence(t *testing.T) { + got, err := Resolve( + map[string]Manifest{ + "assistant": aModelUser(), + "ollama": {Module: "ollama", + Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}}, + }, + []string{"assistant", "ollama"}, + Node{Name: "workstation"}, + World{Licences: map[string][]Record{"model-access": {{Name: "personal"}}}}) + if err != nil { + t.Fatalf("a machine running its own model was asked to choose a licence: %v", err) + } + for _, n := range got.Needs { + if n.ByRecord { + t.Fatal("a record was used although the answer was on this machine") + } + } +} + +// A key that was never supplied is refused by name rather than silently not written. +// +// The mesh discarded the plaintext when the key was accepted and cannot seal another, so a +// machine that resolved cleanly would receive no file and fail at whatever read it. +func TestAModuleOnALicenceWithNoKeyIsRefusedRatherThanLeftEmpty(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant"}}, + } + _, err := r.Declaration(Rendering{}) + if err == nil { + t.Fatal("a module was given a licence with no key, so it receives nothing and fails later") + } + if !strings.Contains(err.Error(), "licence key personal") { + t.Fatalf("the refusal does not say how to fix it: %v", err) + } +} + +// And with a key, both files arrive: what is public, and what is not. +func TestALicenceDeliversWhatIsPublicAndWhatIsSealed(t *testing.T) { + r := Resolution{ + Node: "workstation", + Modules: []Manifest{aModelUser()}, + Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant", + Serves: map[string]any{"model": "a-model"}, Sealed: "sealed-blob"}}, + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + files := map[string]map[string]any{} + for _, res := range out { + if path, ok := res["path"].(string); ok { + files[path] = res + } + } + bound, given := files["/etc/assistant/model.json"] + if !given { + t.Fatal("the consumer was not told what it needs to know that is not secret") + } + content, _ := bound["content"].(string) + if !strings.Contains(content, "a-model") { + t.Fatalf("the binding does not carry what the licence serves:\n%s", content) + } + // The binding says it is a record rather than leaving an empty address, which a reader would + // take for something the mesh failed to fill in. + if !strings.Contains(content, "not a machine") { + t.Fatalf("the binding leaves an empty address with no explanation:\n%s", content) + } + key, delivered := files["/etc/assistant/key"] + if !delivered { + t.Fatal("the key was not delivered") + } + if key["sealed"] != "sealed-blob" { + t.Fatalf("the key is not the sealed one: %+v", key) + } + // And never in the open. The whole arrangement is that what travels is unreadable by + // everything between here and the machine. + if strings.Contains(content, "sealed-blob") { + t.Fatal("the key was written into the public file as well") + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 4b3201c..6725691 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -40,6 +40,18 @@ type World struct { // start meaning something the day a second provider appears, and one recorded and then made // unnecessary should not quietly stop applying either. Pinned map[string]string + // Licences is every provision answered by a **record rather than a node**, by provision name. + // + // novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public + // internet, so the rule that refuses two ends sharing no private network must not apply to + // it. These are the candidates a refusal names. + Licences map[string][]Record + // Using is which record this node's modules were put on, keyed by module then provision. + // + // Per consumer, because that is the whole point: saying WHICH licence a given thing uses. Two + // modules on one machine using different accounts is ordinary rather than a collision. + Using map[string]map[string]Record + // Unchecked takes brokered requirements on trust instead of refusing when nothing answers // them. // @@ -50,6 +62,18 @@ type World struct { Unchecked bool } +// Record is a provision answered by something the mesh holds rather than by a machine. +// +// The name is the operator's — *the personal account*, *the organisation's* — because the whole +// point is saying which one a consumer uses, and an anonymous credential hanging off a provider +// cannot be said (novox/hq ADR 0024). +type Record struct { + // Name is what a person calls it, and what a consumer is put on. + Name string + // Serves is what a consumer must know that is not secret — a base URL, a model name. + Serves map[string]any +} + // Provider is one node answering a mesh-scoped requirement. type Provider struct { // Node is the machine. @@ -101,6 +125,10 @@ type Needed struct { At string // Serves is what the providing module said a consumer needs to know. Serves map[string]any + // ByRecord means this was answered by something the mesh holds rather than by a machine, so + // there is no node to reach and no private network to share. Its credential comes from + // wherever that record's does, not from the pair-wise secret two machines share. + ByRecord bool // Sealed is the credential, closed to this node. Filled in after resolving, because whose // credential it is only becomes answerable once which node answers has been settled. Sealed string @@ -288,6 +316,14 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world candidates := offers[want] switch len(candidates) { case 0: + if len(world.Licences[want]) > 0 { + // Answered by a record rather than by a module, and the post-pass below settles + // which one. Left alone here: the two questions a refusal must answer — *is + // there anything* and *which one* — have different remedies, and answering the + // first wrongly would send somebody looking for a module to install. + reported[want] = true + continue + } reported[want] = true problems = append(problems, fmt.Sprintf( "nothing provides %q, wanted by %s", want, because[want])) @@ -324,6 +360,53 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } } + // What is answered by a record rather than by a machine. + // + // A post-pass, deliberately: nothing about it depends on the order requirements were walked + // in, and putting it in the queue would mean the reachability rule — which must not apply + // here — sitting one branch away from a case it would be wrong for. + // + // **Refused when the consumer has not said which.** ADR 0024 warns this will be felt: a mesh + // holding three ways to reach a model refuses every consumer that has not chosen, which is + // correct and is a great deal of saying-which the first time. So the refusal names the + // candidates and the exact command, because being right is not the same as being usable. + for _, name := range order { + m := catalogue[name] + for _, want := range m.Wants() { + offered, byRecord := world.Licences[want] + if !byRecord || len(offered) == 0 { + continue + } + if satisfied[want] { + // Something in this node's own set answers it -- a model the mesh runs itself, + // most obviously. A record is not consulted when there is a local answer. + continue + } + using, said := world.Using[m.Module][want] + if !said { + if world.Unchecked { + continue + } + names := make([]string, 0, len(offered)) + for _, r := range offered { + names = append(names, r.Name) + } + sort.Strings(names) + problems = append(problems, fmt.Sprintf( + "%s on %s needs %q and has not been told which one to use — say which with "+ + "`licence use %s %s`: %s", + m.Module, node.Name, want, node.Name, m.Module, strings.Join(names, ", "))) + continue + } + needs = append(needs, Needed{ + Name: want, From: using.Name, Serves: using.Serves, ByRecord: true, + // The module that required it, not whatever first mentioned the name: the key is + // sealed per consumer, and a consumer here is a module on a machine. + For: m.Module, + }) + } + } + resolution := Resolution{Node: node.Name, At: node.At, Because: because, Needs: needs} for _, n := range order { resolution.Modules = append(resolution.Modules, catalogue[n]) diff --git a/internal/licences/licences.go b/internal/licences/licences.go new file mode 100644 index 0000000..542a8bc --- /dev/null +++ b/internal/licences/licences.go @@ -0,0 +1,274 @@ +// Package licences is the context that holds which model access exists and who may use it. +// +// novox/hq ADR 0024. It is the first provision answered by a **record rather than a node**: a +// hosted model is on nobody's machine, is reached over the public internet, and the rule that +// refuses two ends sharing no private network must not apply to it. +// +// It owns its store exclusively (novox/hq ADR 0008): a database called `licences`, reached with a +// credential no other context holds — including `inventory`, in the same process. It refers to +// nodes by name, which is what crossing a context boundary is allowed to carry. +package licences + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/novox/mesh-control/internal/secrets" + "github.com/novox/mesh-control/internal/store" +) + +// Name is what this context is called: its database and its credential are named after it. +const Name = "licences" + +// Provision is what a module requires in order to be given one. +// +// One name for all of them, because *which* licence is the operator's choice per consumer rather +// than something a module asks for — a module that required `anthropic` by name could never be +// moved onto a mesh-hosted model without editing it. +const Provision = "model-access" + +//go:embed migrations/*.sql +var files embed.FS + +// Migrations are this context's schema changes, in order. +func Migrations() ([]store.Migration, error) { + return store.LoadMigrations(files, "migrations") +} + +// Licences is this context, holding the store it exclusively owns. +type Licences struct{ store *store.Store } + +// Open connects to the licence store. +func Open(ctx context.Context) (*Licences, error) { + s, err := store.Open(ctx, Name) + if err != nil { + return nil, err + } + return &Licences{store: s}, nil +} + +func (l *Licences) Close() { l.store.Close() } + +// Ready waits for the database to answer. +func (l *Licences) Ready(ctx context.Context, within time.Duration) error { + return l.store.Ready(ctx, within) +} + +// A Licence is one way to reach a model, under the name a person calls it. +type Licence struct { + Name string + Provider string + Serves map[string]any + Added time.Time +} + +// A Holder is one consumer using a licence, and whether it has been given the key. +type Holder struct { + Licence string + Node string + Module string + // Sealed is empty when no key has been supplied since this holder was recorded. + Sealed string +} + +// Add records a licence under the operator's own name for it. +func (l *Licences) Add(ctx context.Context, name, provider string, serves map[string]any) error { + if strings.TrimSpace(name) == "" || strings.TrimSpace(provider) == "" { + return errors.New("a licence needs a name and a provider") + } + if serves == nil { + serves = map[string]any{} + } + body, err := json.Marshal(serves) + if err != nil { + return err + } + _, err = l.store.Pool().Exec(ctx, + `insert into licence (name, provider, serves) values ($1, $2, $3) + on conflict (name) do update set provider = excluded.provider, serves = excluded.serves`, + name, provider, body) + return err +} + +// All is every licence this mesh knows about. +func (l *Licences) All(ctx context.Context) ([]Licence, error) { + rows, err := l.store.Pool().Query(ctx, + `select name, provider, serves, added_at from licence order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Licence + for rows.Next() { + var one Licence + var body []byte + if err := rows.Scan(&one.Name, &one.Provider, &body, &one.Added); err != nil { + return nil, err + } + if err := json.Unmarshal(body, &one.Serves); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +// Forget removes a licence, and with it every record of who held it. +// +// **A licence outliving its holder is a live credential nobody is watching** (ADR 0024). This is +// the other direction and has the same shape: what the mesh no longer grants, it stops naming. +// The key itself is not the mesh's to revoke — that is done where the licence was bought, and +// saying so is more use than pretending otherwise. +func (l *Licences) Forget(ctx context.Context, name string) error { + tag, err := l.store.Pool().Exec(ctx, `delete from licence where name = $1`, name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("this mesh has no licence called %q", name) + } + return nil +} + +// Use records that a consumer holds a licence. +// +// Recorded before any key exists, deliberately. Who uses what is a decision; the key is a value +// somebody supplies afterwards, and often by a different person. +func (l *Licences) Use(ctx context.Context, licence, node, module string) error { + _, err := l.store.Pool().Exec(ctx, + `insert into licence_holder (licence, node, module) values ($1, $2, $3) + on conflict (licence, node, module) do nothing`, licence, node, module) + if err != nil && strings.Contains(err.Error(), "licence_holder_licence_fkey") { + return fmt.Errorf("this mesh has no licence called %q", licence) + } + return err +} + +// StopUsing takes a consumer off a licence, and its sealed key with it. +func (l *Licences) StopUsing(ctx context.Context, licence, node, module string) error { + _, err := l.store.Pool().Exec(ctx, + `delete from licence_holder where licence = $1 and node = $2 and module = $3`, + licence, node, module) + return err +} + +// HoldersOf is every consumer using a licence. +func (l *Licences) HoldersOf(ctx context.Context, licence string) ([]Holder, error) { + rows, err := l.store.Pool().Query(ctx, + `select licence, node, module, coalesce(sealed, '') from licence_holder + where licence = $1 order by node, module`, licence) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Holder + for rows.Next() { + var h Holder + if err := rows.Scan(&h.Licence, &h.Node, &h.Module, &h.Sealed); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + +// Chosen is the licence a consumer was put on, empty if it was put on none. +func (l *Licences) Chosen(ctx context.Context, node, module string) (string, error) { + var name string + err := l.store.Pool().QueryRow(ctx, + `select licence from licence_holder where node = $1 and module = $2`, node, module). + Scan(&name) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return name, err +} + +// KeyFor is the sealed key for one holder, empty if none has been supplied since it was recorded. +func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) { + var sealed *string + err := l.store.Pool().QueryRow(ctx, + `select sealed from licence_holder where licence = $1 and node = $2 and module = $3`, + licence, node, module).Scan(&sealed) + if errors.Is(err, pgx.ErrNoRows) || sealed == nil { + return "", nil + } + if err != nil { + return "", err + } + return *sealed, nil +} + +// SealingKeys is what Accept needs: each holder's node and the key to seal to it. +type SealingKeys func(node string) (string, error) + +// Accept takes a key somebody supplied, seals it to every holder, and discards the plaintext. +// +// **The missing verb** (ADR 0024). Every credential the mesh handles otherwise it generated +// itself; an API key arrives from a person, and a mesh that kept operator-supplied keys readably +// is the arrangement this project measured and rejected. +// +// **It seals to the holders that exist now.** A holder recorded afterwards has no key, and the +// mesh cannot make one — it discarded the only copy. That is reported rather than hidden: the +// remedy is to supply the key again, which is a thing a person can do, and delivering nothing +// while reporting success is not. +func (l *Licences) Accept(ctx context.Context, licence, value string, keys SealingKeys) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, errors.New("an empty key is not a key") + } + holders, err := l.HoldersOf(ctx, licence) + if err != nil { + return 0, err + } + if len(holders) == 0 { + // Refused rather than stored for later, because storing it for later means storing it + // readably — which is the whole thing this refuses to do. + return 0, fmt.Errorf( + "nothing uses %q yet, and the mesh does not keep a key it cannot seal to somebody. "+ + "Put a consumer on it first, then supply the key", licence) + } + + sealed := 0 + for _, h := range holders { + key, err := keys(h.Node) + if err != nil { + return sealed, err + } + if key == "" { + return sealed, fmt.Errorf( + "%s has no sealing key, so nothing can be sealed to it — it joins again to get one", + h.Node) + } + made, err := secrets.Accept(value, key, key) + if err != nil { + return sealed, err + } + if _, err := l.store.Pool().Exec(ctx, + `update licence_holder set sealed = $4, node_key = $5 + where licence = $1 and node = $2 and module = $3`, + h.Licence, h.Node, h.Module, made.ForConsumer, key); err != nil { + return sealed, err + } + sealed++ + } + return sealed, nil +} + +// Names is every licence's name, sorted — what a refusal lists when a consumer has not chosen. +func Names(all []Licence) []string { + out := make([]string, 0, len(all)) + for _, one := range all { + out = append(out, one.Name) + } + sort.Strings(out) + return out +} diff --git a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql new file mode 100644 index 0000000..b3d6742 --- /dev/null +++ b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql @@ -0,0 +1,41 @@ +-- A licence is a named thing, and the name is the operator's. +-- +-- novox/hq ADR 0024. Not an anonymous credential hanging off a provider: *the personal account*, +-- *the organisation's account* are names a person uses, and the mesh has to use them too, because +-- the whole point is saying WHICH ONE a given consumer uses. +-- +-- **Many to many.** One provider has several licences; one licence serves several consumers. So it +-- is deliberately not a claim — claims are for things only one holder may have, and two machines +-- sharing an account is the ordinary case rather than a collision. + +create table licence ( + -- The operator's name for it. The primary key, because that is what a person types and what a + -- consumer is pinned to. + name text primary key, + -- Which service it is for: anthropic, openai, a model the mesh runs itself. + provider text not null, + -- What a consumer needs to know that is not secret -- a base URL, a model name. The key is + -- never here. + serves jsonb not null default '{}'::jsonb, + added_at timestamptz not null default now() +); + +-- Who holds it, and the key sealed to them. +-- +-- **The node is a name, not a foreign key.** It lives in another context and this one may not join +-- across that boundary (novox/hq ADR 0008); a name is the published identifier and is what +-- crossing a context boundary is allowed to carry. +create table licence_holder ( + licence text not null references licence(name) on delete cascade, + node text not null, + module text not null, + + -- Sealed to that node's key. Null until a key has been supplied while this holder existed -- + -- which is a real state and not an error: the mesh discarded the plaintext, so it cannot seal + -- to a holder that arrived afterwards, and saying so is better than delivering nothing. + sealed text, + node_key text, + + added_at timestamptz not null default now(), + primary key (licence, node, module) +);