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)
This commit is contained in:
+1
-21
@@ -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<string, string> = {
|
||||
"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) ------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) },
|
||||
}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 // "<seat>/<node>.<module>"
|
||||
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 `<node>.<module>` 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 "<seat>/<node>.<module>". 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"scheme": "http",
|
||||
"at": "${machine:at}",
|
||||
"port": "${port:9000}",
|
||||
"region": "eu-west"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module minio
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
|
||||
@@ -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=
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user