From 06ca1b7fb97831d443ff6699af7d2753ab9059b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 14:52:14 +0200 Subject: [PATCH] Serve object-store.link: a signed link to one object, signed with the bucket's link key, for the consumer and its shares; retire the root-signed minio_presigned_url (issue 1516) --- modules/minio/client.ts | 22 +- modules/minio/cmd/object-store/main.go | 52 +++ .../minio/cmd/object-store/manifest_test.go | 102 ++++++ modules/minio/cmd/object-store/server.go | 306 ++++++++++++++++++ modules/minio/cmd/object-store/server_test.go | 217 +++++++++++++ modules/minio/files/object-store.json | 6 + modules/minio/go.mod | 5 + modules/minio/go.sum | 2 + modules/minio/module.json | 35 ++ modules/minio/test/links.test.ts | 5 + modules/minio/tools/index.ts | 16 - 11 files changed, 731 insertions(+), 37 deletions(-) create mode 100644 modules/minio/cmd/object-store/main.go create mode 100644 modules/minio/cmd/object-store/manifest_test.go create mode 100644 modules/minio/cmd/object-store/server.go create mode 100644 modules/minio/cmd/object-store/server_test.go create mode 100644 modules/minio/files/object-store.json create mode 100644 modules/minio/go.mod create mode 100644 modules/minio/go.sum diff --git a/modules/minio/client.ts b/modules/minio/client.ts index 710962d3..af29f17a 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -5,7 +5,7 @@ // // It speaks two planes with node built-ins only (never the `minio` npm package): // - the S3 data plane over `fetch`, signed with AWS Signature V4 (node:crypto) — bucket and object -// operations, and presigned URLs; +// operations (links are signed by the object-store seat's Go tool with a link key, never here); // - the admin plane through the `mc` CLI (node:child_process) — scoped service accounts, whose // creation the MinIO admin REST API guards behind an encrypted payload `fetch` cannot form. // This mirrors hal's MinIOClient/MinIOAdmin split, folded into one client the module builds from env. @@ -193,26 +193,6 @@ export class MinioClient { }; } - /** A time-limited URL for GET (download) or PUT (upload) of one object — query-string SigV4. */ - presignedUrl(method: "GET" | "PUT", bucket: string, object: string, expires = 86400): string { - const { amzDate, dateStamp } = this.stamp(); - const scope = `${dateStamp}/${this.region}/s3/aws4_request`; - const host = new URL(this.baseUrl).host; - const params: Record = { - "X-Amz-Algorithm": "AWS4-HMAC-SHA256", - "X-Amz-Credential": `${this.rootUser}/${scope}`, - "X-Amz-Date": amzDate, - "X-Amz-Expires": String(expires), - "X-Amz-SignedHeaders": "host", - }; - const path = uriEncode(`/${bucket}/${object}`, false); - const canonicalQuery = encodeQuery(params); - const canonicalRequest = [method, path, canonicalQuery, `host:${host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n"); - const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); - const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); - return `${this.baseUrl}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`; - } - // --- admin plane (mc CLI) ------------------------------------------------ /** diff --git a/modules/minio/cmd/object-store/main.go b/modules/minio/cmd/object-store/main.go new file mode 100644 index 00000000..c789f03c --- /dev/null +++ b/modules/minio/cmd/object-store/main.go @@ -0,0 +1,52 @@ +// minio's object-store seat (novox/hq ADR 0468 §3, §8): the holder of `object-store`, serving `link`. A party that +// needs to put a document into a bucket, or get one out, asks for one short link and moves the bytes over HTTP +// itself; no document ever travels on the bus. +// +// The link is signed with the bucket's link key (issue 1594), which only reads and writes that bucket's objects, +// never with the root account. Who may have a link is judged here, at each call, from the grant file the mesh +// delivered to minio: the bucket's consumer, and whoever its `shares` name for that method. +// +// stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Words the manifest gives this bundle (modules/minio/module.json). +const ( + ConfigWord = "OBJECT_STORE_CONFIG" // the store as the machine reaches it: files/object-store.json, rendered + LinksWord = "MESH_MINIO_LINKS" // the directory of the link keys' secrets (issue 1594) + ReceivesWord = "MESH_RECEIVES" // the grant file +) + +func main() { + s, err := newServer(os.Getenv(ConfigWord), os.Getenv(LinksWord), os.Getenv(ReceivesWord)) + if err != nil { + fmt.Fprintf(os.Stderr, "[object-store] %v\n", err) + os.Exit(1) + } + if err := stdio.Serve("", tools(s)); err != nil { + fmt.Fprintf(os.Stderr, "[object-store] %v\n", err) + os.Exit(1) + } +} + +func tools(s *server) []stdio.Tool { + return []stdio.Tool{{ + Name: "object-store.link", + Description: "A signed link to one object of a bucket, for one method, that lives at most ten minutes: the caller " + + "moves the bytes over HTTP to it and never over the bus. Allowed to the bucket's own consumer and to whoever the " + + "consumer's shares name for the method; the link is signed with a key that reaches that bucket's objects only.", + Input: map[string]any{ + "bucket": map[string]any{"type": "string", "description": "the bucket"}, + "key": map[string]any{"type": "string", "description": "the object's key"}, + "method": map[string]any{"type": "string", "description": "get to read the object, put to write it"}, + "seconds": map[string]any{"type": "integer", "description": "how long the link lives, 1 to 600 (default 300)"}, + }, + Run: func(args map[string]any) (any, error) { return s.link(stdio.CallerOf(args), args) }, + }} +} diff --git a/modules/minio/cmd/object-store/manifest_test.go b/modules/minio/cmd/object-store/manifest_test.go new file mode 100644 index 00000000..ab2bc4ea --- /dev/null +++ b/modules/minio/cmd/object-store/manifest_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func manifest(t *testing.T) map[string]any { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +// 1516/AC6: minio claims object-store (scope mesh) serving link, invokes seat:mesh-controller.seats, builds the Go +// bundle, and no tool named minio_presigned_url remains. +func TestManifestClaimsTheSeat(t *testing.T) { + m := manifest(t) + claims := m["claims"].([]any) + if len(claims) != 1 { + t.Fatalf("claims: %v", claims) + } + c := claims[0].(map[string]any) + serves := c["serves"].([]any) + if c["name"] != "object-store" || c["scope"] != "mesh" || len(serves) != 1 || serves[0] != "link" { + t.Fatalf("claim: %v", c) + } + inv := m["invokes"].([]any) + if len(inv) != 1 || inv[0] != "seat:mesh-controller.seats" { + t.Fatalf("invokes: %v", inv) + } + var goBundle map[string]any + for _, a := range m["build"].(map[string]any)["artifacts"].([]any) { + if a.(map[string]any)["from"] == "cmd/object-store" { + goBundle = a.(map[string]any) + } + } + if goBundle == nil || goBundle["language"] != "go" || goBundle["kind"] != "bundle" { + t.Fatalf("no Go bundle for cmd/object-store: %v", goBundle) + } + env := goBundle["env"].(map[string]any) + for _, word := range []string{ConfigWord, LinksWord, ReceivesWord} { + if env[word] == nil { + t.Errorf("the Go bundle's env lacks %s", word) + } + } + for _, f := range []string{"../../tools/index.ts", "../../client.ts"} { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "minio_presigned_url") || strings.Contains(string(raw), "presignedUrl") { + t.Errorf("%s still has the root-signed link", f) + } + } +} + +// 1516/AC1: the file the Go tool reads is rendered from files/object-store.json with the facts the s3-bucket binding +// renders for a consumer: at is the providing machine's `at`, port the S3 endpoint's port (brother's files/store.json +// is the writer of that shape: scheme, at, port, region). +func TestStoreFileSaysWhatTheBindingSays(t *testing.T) { + raw, err := os.ReadFile("../../files/object-store.json") + if err != nil { + t.Fatal(err) + } + var f map[string]string + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if f["at"] != "${machine:at}" || f["port"] != "${port:9000}" || f["scheme"] != "http" || f["region"] != "eu-west" { + t.Fatalf("object-store.json: %v", f) + } + brother, err := os.ReadFile("../../../brother/files/store.json") + if err != nil { + t.Fatal(err) + } + var b map[string]string + if err := json.Unmarshal(brother, &b); err != nil { + t.Fatal(err) + } + for _, k := range []string{"scheme", "at", "port", "region"} { + if _, ok := b[k]; !ok { + t.Errorf("brother's store.json has no %s: the shape moved", k) + } + if _, ok := f[k]; !ok { + t.Errorf("object-store.json has no %s", k) + } + } + // The same values: minio's serves.s3-bucket.port is the port its S3 listen declares. + m := manifest(t) + if port := m["serves"].(map[string]any)["s3-bucket"].(map[string]any)["port"]; port != float64(9000) { + t.Errorf("serves port %v", port) + } +} diff --git a/modules/minio/cmd/object-store/server.go b/modules/minio/cmd/object-store/server.go new file mode 100644 index 00000000..9b4b59e5 --- /dev/null +++ b/modules/minio/cmd/object-store/server.go @@ -0,0 +1,306 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/objectlink" +) + +// DefaultSeconds is the life of a link when none is asked; MaxSeconds is the longest (ADR 0468 §3). +const ( + DefaultSeconds = 300 + MaxSeconds = 600 + holdersFor = 60 * time.Second +) + +// storeConfig is the store as this machine reaches it. The file is rendered by the mesh from files/object-store.json +// with the same facts the s3-bucket binding renders for a consumer (`at` is the providing machine's, `port` the one +// it gave the S3 endpoint), so a link names the host and port every consumer's own file names. +type storeConfig struct { + Scheme string `json:"scheme"` + At string `json:"at"` + Port string `json:"port"` + Region string `json:"region"` +} + +func (c storeConfig) endpoint() string { return c.Scheme + "://" + c.At + ":" + c.Port } + +func readConfig(path string) (storeConfig, error) { + var c storeConfig + raw, err := os.ReadFile(path) + if err != nil { + return c, fmt.Errorf("the store's address is unreadable (%s): %w", path, err) + } + if err := json.Unmarshal(raw, &c); err != nil { + return c, fmt.Errorf("the store's address file %s is not JSON: %w", path, err) + } + if c.Scheme == "" || c.At == "" || c.Port == "" { + return c, fmt.Errorf("the store's address file %s lacks a scheme, an at or a port", path) + } + return c, nil +} + +// linkKeyID is the access key id of a bucket's link key, as the provisioner makes it (modules/minio/links.ts). +func linkKeyID(bucket string) string { + sum := sha256.Sum256([]byte(bucket)) + return "link" + hex.EncodeToString(sum[:])[:16] +} + +type server struct { + configPath, linksDir, receives string + now func() time.Time + // seats asks the controller for its seats; a test replaces it. + seats func() (json.RawMessage, error) + + mu sync.Mutex + holders map[string]bool // "/." + readAt time.Time + haveSet bool +} + +func newServer(config, links, receives string) (*server, error) { + for word, v := range map[string]string{ConfigWord: config, LinksWord: links, ReceivesWord: receives} { + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("%s is not given: the manifest names it", word) + } + } + return &server{configPath: config, linksDir: links, receives: receives, now: time.Now, seats: askSeats}, nil +} + +func askSeats() (json.RawMessage, error) { + return stdio.Ask("seat:mesh-controller.seats", map[string]any{}) +} + +// link answers one ask. +func (s *server) link(caller string, args map[string]any) (any, error) { + bucket, _ := args["bucket"].(string) + key, _ := args["key"].(string) + method, _ := args["method"].(string) + if bucket == "" { + return nil, errors.New("link: bucket is needed") + } + if key == "" { + return nil, errors.New("link: key is needed") + } + if method != "get" && method != "put" { + return nil, fmt.Errorf("link: method %q is not get or put", method) + } + seconds := DefaultSeconds + if v, given := args["seconds"]; given { + f, ok := v.(float64) + if !ok || f != float64(int(f)) { + return nil, fmt.Errorf("link: seconds %v is not a whole number", v) + } + seconds = int(f) + } + if seconds < 1 || seconds > MaxSeconds { + return nil, fmt.Errorf("link: seconds %d is outside 1 to %d: a link lives at most ten minutes (ADR 0468 §3)", seconds, MaxSeconds) + } + node, module, ok := splitCaller(caller) + if !ok { + return nil, fmt.Errorf("link: the caller is %q: the runtime did not prove who asks, and a link is not given to an unproven caller", caller) + } + grant, err := s.grantOf(bucket) + if err != nil { + return nil, err + } + if err := s.allowed(grant, node, module, method); err != nil { + return nil, fmt.Errorf("link: %s.%s may not %s in bucket %s: %w", node, module, method, bucket, err) + } + cfg, err := readConfig(s.configPath) + if err != nil { + return nil, err + } + secret, err := os.ReadFile(filepath.Join(s.linksDir, bucket+".secret")) + if err != nil || strings.TrimSpace(string(secret)) == "" { + return nil, fmt.Errorf("link: bucket %s has no link key secret yet (%s); minio's provisioner makes it", bucket, filepath.Join(s.linksDir, bucket+".secret")) + } + now := s.now() + url, err := objectlink.Presign(objectlink.Request{ + Method: strings.ToUpper(method), Endpoint: cfg.endpoint(), Bucket: bucket, Key: key, + AccessKey: linkKeyID(bucket), SecretKey: strings.TrimSpace(string(secret)), Region: cfg.Region, + Expires: time.Duration(seconds) * time.Second, Now: now, + }) + if err != nil { + return nil, err + } + return map[string]any{"link": url, "method": method, "seconds": seconds, "expires-at": now.Add(time.Duration(seconds) * time.Second).UTC().Format(time.RFC3339)}, nil +} + +// splitCaller splits the bus user `.` the runtime proved. +func splitCaller(caller string) (node, module string, ok bool) { + if caller == "" || caller == stdio.Unproven { + return "", "", false + } + node, module, ok = strings.Cut(caller, ".") + if !ok || node == "" || module == "" || strings.Contains(module, ".") { + return "", "", false + } + return node, module, true +} + +// grant is what the mesh delivered for one bucket: its consumer and the consumer's shares. +type grant struct { + consumerModule, consumerNode string + shares []share +} + +type share struct { + To string `json:"to"` + Methods []string `json:"methods"` +} + +// grantOf reads the grant file now (never cached, so a change of shares applies to the next ask) and finds the entry +// whose derived bucket is this one. The entry's shape is the mesh's Contribution (mesh-sdk go/catalogue/declaration.go +// `Contribution`: from, node, as, values, derived) inside `given` (receivedFile in the same file). +func (s *server) grantOf(bucket string) (grant, error) { + raw, err := os.ReadFile(s.receives) + if err != nil { + return grant{}, fmt.Errorf("link: the grant file is unreadable (%s): %w", s.receives, err) + } + var doc struct { + Given []struct { + From string `json:"from"` + Node string `json:"node"` + Values struct { + Shares []share `json:"shares"` + } `json:"values"` + Derived struct { + Bucket string `json:"bucket"` + } `json:"derived"` + } `json:"given"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return grant{}, fmt.Errorf("link: the grant file %s is not JSON: %w", s.receives, err) + } + for _, g := range doc.Given { + if g.Derived.Bucket == bucket { + return grant{consumerModule: g.From, consumerNode: g.Node, shares: g.Values.Shares}, nil + } + } + return grant{}, fmt.Errorf("link: no grant names bucket %s: nobody is given it", bucket) +} + +// allowed judges the caller: the bucket's consumer, or a share for this method naming the caller's module, or a seat +// the caller's module holds. +func (s *server) allowed(g grant, node, module, method string) error { + if g.consumerModule == module && (g.consumerNode == "" || g.consumerNode == node) { + return nil + } + // A share names the caller when its `to` is the caller's module, or a seat the caller's module holds. The + // controller is asked only for a seat share, and only when nothing earlier decided. + names := func(sh share) (bool, error) { + if sh.To == module { + return true, nil + } + if seat, isSeat := strings.CutPrefix(sh.To, "seat:"); isSeat { + held, err := s.holds(seat, node, module) + if err != nil { + return false, fmt.Errorf("a share names seat %s and the controller's seats cannot be read to tell whether this caller holds it: %w", seat, err) + } + return held, nil + } + return false, nil + } + var unread error + for _, sh := range g.shares { + if !has(sh.Methods, method) { + continue + } + named, err := names(sh) + if err != nil { + unread = err + continue + } + if named { + return nil + } + } + if unread != nil { + return unread + } + for _, sh := range g.shares { + if has(sh.Methods, method) { + continue + } + if named, err := names(sh); err == nil && named { + return fmt.Errorf("the bucket's shares give this caller %s, not %s", strings.Join(sh.Methods, " and "), method) + } + } + return errors.New("it is not the bucket's consumer and no share of the bucket names it for that method") +} + +func has(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// holds says whether node.module holds the seat, from the controller's seats, read at most once a minute. A read that +// failed is not cached: the next ask reads again. +func (s *server) holds(seat, node, module string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.haveSet || s.now().Sub(s.readAt) >= holdersFor { + raw, err := s.seats() + if err != nil { + return false, err + } + set, err := holdersIn(raw) + if err != nil { + return false, err + } + s.holders, s.readAt, s.haveSet = set, s.now(), true + } + return s.holders[seat+"/"+node+"."+module], nil +} + +// holdersIn reads the controller's `seats` answer (answer.seats[].holders[] of {node, module}, and answer.outside[] of +// {Claim, Node, Module}) into "/.". The answer may arrive wrapped in {"answer": ...}. +func holdersIn(raw json.RawMessage) (map[string]bool, error) { + var doc struct { + Answer json.RawMessage `json:"answer"` + Seats []struct { + Seat string `json:"seat"` + Holders []struct { + Node string `json:"node"` + Module string `json:"module"` + } `json:"holders"` + } `json:"seats"` + Outside []struct { + Claim, Node, Module string + } `json:"outside"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("the controller's seats answer is not JSON: %w", err) + } + if len(doc.Answer) > 0 && string(doc.Answer) != "null" { + return holdersIn(doc.Answer) + } + if doc.Seats == nil && doc.Outside == nil { + return nil, errors.New("the controller's seats answer holds neither seats nor outside claims") + } + out := map[string]bool{} + for _, s := range doc.Seats { + for _, h := range s.Holders { + out[s.Seat+"/"+h.Node+"."+h.Module] = true + } + } + for _, o := range doc.Outside { + out[o.Claim+"/"+o.Node+"."+o.Module] = true + } + return out, nil +} diff --git a/modules/minio/cmd/object-store/server_test.go b/modules/minio/cmd/object-store/server_test.go new file mode 100644 index 00000000..2f944a27 --- /dev/null +++ b/modules/minio/cmd/object-store/server_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "errors" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The grant entry's shape is the mesh's Contribution (mesh-sdk go/catalogue/declaration.go: from, node, as, values, +// derived), written in a `given` list by receivedFile in the same file; `shares` is written into values by +// go/catalogue/bucket_shares.go withShares: [{"to": ..., "methods": [...]}]. +const grantFile = `{ + "contributions": 1, + "requirement": "s3-bucket", + "given": [ + {"from": "brother", "node": "shanks", "as": "brother-key", + "values": {"shares": [{"to": "seat:node-files", "methods": ["put"]}, {"to": "print-and-scan", "methods": ["get"]}]}, + "derived": {"bucket": "mesh-shanks-brother"}}, + {"from": "nextcloud", "node": "novox", "as": "nextcloud-key", "derived": {"bucket": "mesh-novox-nextcloud"}} + ] +}` + +// The seats answer is the controller's (mesh-controller.seats read on 2026-10-21): seats[].holders[] of {node, module}, +// and outside[] of {Claim, Node, Module}. +const seatsAnswer = `{"answer": {"seats": [ + {"seat": "node-files", "scope": "node", "holders": [{"node": "g14", "module": "mesh-files"}, {"node": "shanks", "module": "mesh-files"}]}, + {"seat": "node-backup", "scope": "node", "holders": [{"node": "g14", "module": "restic"}]} + ], "outside": [{"Claim": "printer", "Scope": "mesh", "Node": "shanks", "Module": "brother", "Site": "home", "Kind": ""}]}}` + +type rig struct { + s *server + dir string + now time.Time + seatsCalls int + seatsErr error +} + +func newRig(t *testing.T) *rig { + t.Helper() + dir := t.TempDir() + r := &rig{dir: dir, now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} + write := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p + } + // What the mesh renders from files/object-store.json for a store on novox.internal, port 20001. + cfg := write("object-store.json", `{"scheme":"http","at":"novox.internal","port":"20001","region":"eu-west"}`) + write("links/mesh-shanks-brother.secret", "brother-link-secret\n") + write("links/mesh-novox-nextcloud.secret", "nextcloud-link-secret\n") + grants := write("grants/mesh.json", grantFile) + s, err := newServer(cfg, filepath.Join(dir, "links"), grants) + if err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return r.now } + s.seats = func() (json.RawMessage, error) { + r.seatsCalls++ + if r.seatsErr != nil { + return nil, r.seatsErr + } + return json.RawMessage(seatsAnswer), nil + } + r.s = s + return r +} + +func ask(bucket, method string, seconds any) map[string]any { + a := map[string]any{"bucket": bucket, "key": "doc.pdf", "method": method} + if seconds != nil { + a["seconds"] = seconds + } + return a +} + +func linkOf(t *testing.T, got any) *url.URL { + t.Helper() + m := got.(map[string]any) + u, err := url.Parse(m["link"].(string)) + if err != nil { + t.Fatal(err) + } + return u +} + +// 1516/AC1: brother's own ask gets a link signed with the bucket's link key, for the store's host and port, 300 s. +func TestLinkIsSignedWithTheBucketsLinkKey(t *testing.T) { + r := newRig(t) + got, err := r.s.link("shanks.brother", ask("mesh-shanks-brother", "get", float64(300))) + if err != nil { + t.Fatal(err) + } + u := linkOf(t, got) + if u.Host != "novox.internal:20001" || u.Scheme != "http" || u.Path != "/mesh-shanks-brother/doc.pdf" { + t.Fatalf("link goes to %s", u) + } + q := u.Query() + if cred := q.Get("X-Amz-Credential"); !strings.HasPrefix(cred, linkKeyID("mesh-shanks-brother")+"/20261021/eu-west/s3/") { + t.Fatalf("credential %q is not the bucket's link key", cred) + } + if strings.Contains(u.String(), "meshroot") { + t.Fatal("the root account signed a link") + } + if q.Get("X-Amz-Expires") != "300" || q.Get("X-Amz-Date") != "20261021T120000Z" { + t.Fatalf("expiry %q at %q", q.Get("X-Amz-Expires"), q.Get("X-Amz-Date")) + } +} + +// 1516/AC1: the link key id is the one the provisioner makes (modules/minio/links.ts linkKeyId, tested there with the +// same literal). +func TestLinkKeyIDMatchesTheProvisioners(t *testing.T) { + if got := linkKeyID("brother"); got != "link0b4bd77cec705cf5" { + t.Fatalf("linkKeyID = %s", got) + } +} + +// 1516/AC2: a share to seat:node-files for put, mesh-files holding node-files on g14: put given, get refused naming the method. +func TestSeatShareGivesTheHoldersTheSharedMethodOnly(t *testing.T) { + r := newRig(t) + if _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)); err != nil { + t.Fatalf("put: %v", err) + } + _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "get", nil)) + if err == nil || !strings.Contains(err.Error(), "put, not get") { + t.Fatalf("get was not refused naming the method: %v", err) + } + // A module share is judged by name alone. + if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err != nil { + t.Fatalf("print-and-scan get: %v", err) + } +} + +// 1516/AC3: each of these is refused with its reason. +func TestRefusals(t *testing.T) { + r := newRig(t) + cases := []struct { + name, caller string + args map[string]any + want string + }{ + {"a stranger", "g14.restic", ask("mesh-shanks-brother", "get", nil), "not the bucket's consumer"}, + {"a seat it does not hold", "g14.restic", ask("mesh-shanks-brother", "put", nil), "not the bucket's consumer"}, + {"unproven", "unproven", ask("mesh-shanks-brother", "get", nil), "did not prove"}, + {"no grant", "shanks.brother", ask("no-such-bucket", "get", nil), "no grant names bucket"}, + {"seconds over 600", "shanks.brother", ask("mesh-shanks-brother", "get", float64(601)), "at most ten minutes"}, + {"seconds zero", "shanks.brother", ask("mesh-shanks-brother", "get", float64(0)), "outside 1 to 600"}, + {"method delete", "shanks.brother", ask("mesh-shanks-brother", "delete", nil), "not get or put"}, + {"another consumer's bucket", "shanks.brother", ask("mesh-novox-nextcloud", "get", nil), "not the bucket's consumer"}, + } + for _, c := range cases { + _, err := r.s.link(c.caller, c.args) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: want a refusal containing %q, got %v", c.name, c.want, err) + } + } +} + +// 1516/AC4: with the controller unreadable a seat share is refused naming why; a good read serves a second ask within +// 60 s from the cache and is read again after. +func TestSeatsUnreadableAndCached(t *testing.T) { + r := newRig(t) + r.seatsErr = errors.New("the controller did not answer") + _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)) + if err == nil || !strings.Contains(err.Error(), "the controller did not answer") || !strings.Contains(err.Error(), "node-files") { + t.Fatalf("not refused naming why: %v", err) + } + r.seatsErr = nil + put := func() { + t.Helper() + if _, err := r.s.link("g14.mesh-files", ask("mesh-shanks-brother", "put", nil)); err != nil { + t.Fatal(err) + } + } + put() // a failed read is not cached: this reads again + put() // served from the cache + if r.seatsCalls != 2 { + t.Fatalf("the controller was asked %d times, want 2 (one failure, one good read)", r.seatsCalls) + } + r.now = r.now.Add(59 * time.Second) + put() + if r.seatsCalls != 2 { + t.Fatalf("an ask within 60 s read the seats again (%d calls)", r.seatsCalls) + } + r.now = r.now.Add(2 * time.Second) + put() + if r.seatsCalls != 3 { + t.Fatalf("the seats were not read again after 60 s (%d calls)", r.seatsCalls) + } +} + +// 1516/AC5: the grant file is read at each call. +func TestGrantFileIsReadAtEachCall(t *testing.T) { + r := newRig(t) + if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err != nil { + t.Fatal(err) + } + changed := strings.Replace(grantFile, `{"to": "print-and-scan", "methods": ["get"]}`, `{"to": "someone-else", "methods": ["get"]}`, 1) + if err := os.WriteFile(r.s.receives, []byte(changed), 0o600); err != nil { + t.Fatal(err) + } + if _, err := r.s.link("novox.print-and-scan", ask("mesh-shanks-brother", "get", nil)); err == nil { + t.Fatal("the second call judged by the old shares") + } + if _, err := r.s.link("novox.someone-else", ask("mesh-shanks-brother", "get", nil)); err != nil { + t.Fatal(err) + } +} diff --git a/modules/minio/files/object-store.json b/modules/minio/files/object-store.json new file mode 100644 index 00000000..d2970bcc --- /dev/null +++ b/modules/minio/files/object-store.json @@ -0,0 +1,6 @@ +{ + "scheme": "http", + "at": "${machine:at}", + "port": "${port:9000}", + "region": "eu-west" +} diff --git a/modules/minio/go.mod b/modules/minio/go.mod new file mode 100644 index 00000000..c09ccfda --- /dev/null +++ b/modules/minio/go.mod @@ -0,0 +1,5 @@ +module minio + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f diff --git a/modules/minio/go.sum b/modules/minio/go.sum new file mode 100644 index 00000000..9728ce93 --- /dev/null +++ b/modules/minio/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/minio/module.json b/modules/minio/module.json index 72614a58..8916d5a8 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -18,6 +18,18 @@ "requires": [ "route" ], + "claims": [ + { + "name": "object-store", + "scope": "mesh", + "serves": [ + "link" + ] + } + ], + "invokes": [ + "seat:mesh-controller.seats" + ], "contributes": { "route": { "api": { @@ -108,6 +120,13 @@ "mode": "0600", "template": "files/root.env" }, + { + "id": "object-store", + "type": "file", + "path": "${dir:state}/object-store.json", + "mode": "0600", + "template": "files/object-store.json" + }, { "id": "data", "type": "directory", @@ -179,6 +198,22 @@ "MESH_RECEIVES": "${dir:grants}/mesh.json" } }, + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/object-store", + "binary": "object-store", + "loads": [ + "object-store" + ], + "env": { + "OBJECT_STORE_CONFIG": "${dir:state}/object-store.json", + "MESH_MINIO_LINKS": "${dir:state}/links", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } + }, { "name": "files", "kind": "archive", diff --git a/modules/minio/test/links.test.ts b/modules/minio/test/links.test.ts index 68e3f6b9..62de8e75 100644 --- a/modules/minio/test/links.test.ts +++ b/modules/minio/test/links.test.ts @@ -110,3 +110,8 @@ test("a link key id fits an access key (20 characters) whatever the bucket name" assert.equal(linkKeyId("a".repeat(63)).length, 20); assert.notEqual(linkKeyId("brother"), linkKeyId("mesh-mail")); }); + +test("the link key id is the one the object-store tool derives (cmd/object-store server_test.go holds the same literal)", () => { + // 1516/AC1 + assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5"); +}); diff --git a/modules/minio/tools/index.ts b/modules/minio/tools/index.ts index cd192825..35833579 100644 --- a/modules/minio/tools/index.ts +++ b/modules/minio/tools/index.ts @@ -50,22 +50,6 @@ export function getMinioTools(minio: MinioClient): ToolDefinition[] { input: { bucket: { type: "string", description: "the bucket name" } }, run: async (args) => minio.bucketInfo(String(args.bucket)), }, - { - name: "minio_presigned_url", - description: "A time-limited URL to download (GET) or upload (PUT) one object without credentials.", - input: { - bucket: { type: "string", description: "the bucket name" }, - object: { type: "string", description: "the object key" }, - method: { type: "string", description: "'GET' to download (default) or 'PUT' to upload" }, - expires: { type: "number", description: "seconds until the URL expires (default 86400 = 24h)" }, - }, - run: async (args) => { - const method = String(args.method ?? "GET").toUpperCase() === "PUT" ? "PUT" : "GET"; - const expires = args.expires ? Number(args.expires) : 86400; - const url = minio.presignedUrl(method, String(args.bucket), String(args.object), expires); - return { method, bucket: String(args.bucket), object: String(args.object), expiresInSeconds: expires, url }; - }, - }, ]; }