From 4a9adbc96c8f74f675be8dd7a536aaa3027744db Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:03:38 +0200 Subject: [PATCH 1/4] feat(fctl-plugin): extract standalone Auth command module --- misc/fctl-plugin/auth.go | 173 +++++++ misc/fctl-plugin/auth_test.go | 423 ++++++++++++++++++ misc/fctl-plugin/cmd/fctl-plugin-auth/main.go | 86 ++++ misc/fctl-plugin/go.mod | 27 ++ misc/fctl-plugin/go.sum | 64 +++ misc/fctl-plugin/interaction.go | 74 +++ misc/fctl-plugin/interaction_test.go | 222 +++++++++ misc/fctl-plugin/manifest.go | 50 +++ misc/fctl-plugin/payload.go | 113 +++++ 9 files changed, 1232 insertions(+) create mode 100644 misc/fctl-plugin/auth.go create mode 100644 misc/fctl-plugin/auth_test.go create mode 100644 misc/fctl-plugin/cmd/fctl-plugin-auth/main.go create mode 100644 misc/fctl-plugin/go.mod create mode 100644 misc/fctl-plugin/go.sum create mode 100644 misc/fctl-plugin/interaction.go create mode 100644 misc/fctl-plugin/interaction_test.go create mode 100644 misc/fctl-plugin/manifest.go create mode 100644 misc/fctl-plugin/payload.go diff --git a/misc/fctl-plugin/auth.go b/misc/fctl-plugin/auth.go new file mode 100644 index 0000000..88926df --- /dev/null +++ b/misc/fctl-plugin/auth.go @@ -0,0 +1,173 @@ +// Package auth implements the Auth plugin using the public generated Auth SDK. +package auth + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + sdk "github.com/formancehq/auth/pkg/client" + "github.com/formancehq/auth/pkg/client/models/components" + "github.com/formancehq/auth/pkg/client/models/operations" + "github.com/formancehq/fctl/pkg/pluginsdk" + httpadapter "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const defaultServiceVersion = "dev" + +type plugin struct { + client *http.Client + serviceVersion string +} +type response interface { + GetHTTPMeta() components.HTTPMetadata +} + +// New creates an Auth plugin with the caller's authenticated HTTP client. +func New(httpClient *http.Client) pluginsdk.Plugin { + return NewVersion(httpClient, defaultServiceVersion) +} + +// NewVersion creates a plugin for the exact co-released Auth service version. +// An empty version uses dev. Versions belong to instances, never global state. +func NewVersion(httpClient *http.Client, serviceVersion string) pluginsdk.Plugin { + return &plugin{client: httpClient, serviceVersion: cmp.Or(serviceVersion, defaultServiceVersion)} +} + +func (p *plugin) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.Manifest{}, err + } + m := manifest() + m.Version = p.serviceVersion + return m, nil +} + +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + m := manifest() + m.Version = p.serviceVersion + req, err := pluginsdk.NormalizeRequest(m, req) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if err = validateIDs(req.Args); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if p.client == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("auth plugin requires an HTTP client") + } + conn, err := httpadapter.New(req.Endpoint, p.client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + s := sdk.New(sdk.WithServerURL(conn.Endpoint()), sdk.WithClient(conn.HTTPClient())) + res, err := dispatch(ctx, s.Auth.V1, req) + data, err := result(res, err) + if err == nil && strings.Join(req.CommandPath, " ") == "auth clients secrets list" { + data, err = secretList(data) + } + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return pluginsdk.ExecuteResponse{Data: data}, nil +} +func validateIDs(args []string) error { + for _, id := range args { + if strings.TrimSpace(id) == "" || id == "." || id == ".." { + return fmt.Errorf("identifier must be non-empty and cannot be a dot or double dot") + } + } + return nil +} +func dispatch(ctx context.Context, s *sdk.V1, req pluginsdk.ExecuteRequest) (response, error) { + switch strings.Join(req.CommandPath, " ") { + case "auth info": + return s.GetServerInfo(ctx) + case "auth discovery": + return s.GetOIDCWellKnowns(ctx) + case "auth clients list": + return s.ListClients(ctx) + case "auth clients show", "auth clients secrets list": + return s.ReadClient(ctx, operations.ReadClientRequest{ClientID: url.PathEscape(req.Args[0])}) + case "auth clients create": + return createClient(ctx, s, req.Body) + case "auth clients update": + return updateClient(ctx, s, req.Args[0], req.Body) + case "auth clients delete": + return s.DeleteClient(ctx, operations.DeleteClientRequest{ClientID: url.PathEscape(req.Args[0])}) + case "auth clients secrets create": + return createSecret(ctx, s, req.Args[0], req.Body) + case "auth clients secrets delete": + return s.DeleteSecret(ctx, operations.DeleteSecretRequest{ClientID: url.PathEscape(req.Args[0]), SecretID: url.PathEscape(req.Args[1])}) + case "auth users list": + return s.ListUsers(ctx) + case "auth users show": + return s.ReadUser(ctx, operations.ReadUserRequest{UserID: url.PathEscape(req.Args[0])}) + default: + return nil, fmt.Errorf("unsupported auth command %q", req.CommandPath) + } +} +func createClient(ctx context.Context, s *sdk.V1, data json.RawMessage) (response, error) { + var req components.CreateClientRequest + if err := decodeObject(data, &req); err != nil { + return nil, err + } + if strings.TrimSpace(req.Name) == "" { + return nil, fmt.Errorf("--data requires a non-empty name") + } + return s.CreateClient(ctx, &req) +} +func createSecret(ctx context.Context, s *sdk.V1, id string, data json.RawMessage) (response, error) { + var req components.CreateSecretRequest + if err := decodeObject(data, &req); err != nil { + return nil, err + } + if strings.TrimSpace(req.Name) == "" { + return nil, fmt.Errorf("--data requires a non-empty name") + } + return s.CreateSecret(ctx, operations.CreateSecretRequest{ClientID: url.PathEscape(id), CreateSecretRequest: &req}) +} +func updateClient(ctx context.Context, s *sdk.V1, id string, data json.RawMessage) (response, error) { + var patch components.UpdateClientRequest + if err := decodeObject(data, &patch); err != nil { + return nil, err + } + current, err := s.ReadClient(ctx, operations.ReadClientRequest{ClientID: url.PathEscape(id)}) + if err != nil { + return current, err + } + body, err := responseBody(current) + if err != nil { + return nil, err + } + merged, err := mergeOptions(body, data) + if err != nil { + return nil, err + } + var req components.UpdateClientRequest + if err = decodeObject(merged, &req); err != nil { + return nil, err + } + if strings.TrimSpace(req.Name) == "" { + return nil, fmt.Errorf("--data requires a non-empty name") + } + if err := ctx.Err(); err != nil { + return nil, err + } + return s.UpdateClient(ctx, operations.UpdateClientRequest{ClientID: url.PathEscape(id), UpdateClientRequest: &req}) +} + +// Auth errors leave stdout empty; the host reports the error separately. +func result(res response, err error) (json.RawMessage, error) { + if err != nil { + return nil, err + } + return responseBody(res) +} diff --git a/misc/fctl-plugin/auth_test.go b/misc/fctl-plugin/auth_test.go new file mode 100644 index 0000000..3c1e333 --- /dev/null +++ b/misc/fctl-plugin/auth_test.go @@ -0,0 +1,423 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/formancehq/auth/pkg/client/models/components" + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func execute(t *testing.T, req pluginsdk.ExecuteRequest, transport roundTrip) (pluginsdk.ExecuteResponse, error) { + t.Helper() + client := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + if r.Context() != t.Context() { + t.Fatal("lost request context") + } + r.Header.Set("Authorization", "Bearer injected") + return transport(r) + })} + req.Endpoint = "https://example.test/api/auth/" + return New(client).Execute(t.Context(), req) +} +func request(path string, args []string, body string, confirm bool) pluginsdk.ExecuteRequest { + req := pluginsdk.ExecuteRequest{CommandPath: strings.Fields("auth " + path), Args: args, Flags: map[string]string{}, ChangedFlags: map[string]bool{}} + if body != "" { + req.Body = json.RawMessage(body) + req.Flags["data"] = "-" + req.ChangedFlags["data"] = true + } + if confirm { + req.Flags["confirm"] = "true" + req.ChangedFlags["confirm"] = true + } + return req +} +func reply(code int, body string) *http.Response { + return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} +} + +type commandTest struct { + path string + args []string + body string + confirm bool + method, url, response, want string +} + +func TestCommands(t *testing.T) { + cases := []commandTest{ + {"info", nil, "", false, "GET", "/_info", `{"version":"2.5.0","precision":9007199254740993}`, "9007199254740993"}, + {"discovery", nil, "", false, "GET", "/.well-known/openid-configuration", `{"issuer":"https://example.test","token_endpoint":"https://example.test/oauth/token"}`, "token_endpoint"}, + {"clients list", nil, "", false, "GET", "/clients", `{"data":[]}`, `"data"`}, + {"clients show", []string{"c1"}, "", false, "GET", "/clients/c1", `{"data":{"id":"c1","name":"test","extra":9007199254740993}}`, "9007199254740993"}, + {"clients create", nil, `{"name":"new","public":false,"scopes":["auth:read"],"metadata":{"env":"test"}}`, false, "POST", "/clients", `{"data":{"id":"c1","name":"new"}}`, `"new"`}, + {"clients delete", []string{"c1"}, "", true, "DELETE", "/clients/c1", "", "null"}, + {"clients secrets create", []string{"c1"}, `{"name":"secret","metadata":{"env":"test"}}`, false, "POST", "/clients/c1/secrets", `{"data":{"id":"s1","name":"secret","clear":"one-time"}}`, "one-time"}, + {"clients secrets delete", []string{"c1", "s1"}, "", true, "DELETE", "/clients/c1/secrets/s1", "", "null"}, + {"clients secrets list", []string{"c1"}, "", false, "GET", "/clients/c1", `{"data":{"id":"c1","name":"test","secrets":[{"id":"s1","name":"secret","lastDigits":"1234","extra":9007199254740993}]}}`, "9007199254740993"}, + {"users list", nil, "", false, "GET", "/users", `{"data":[]}`, `"data"`}, + {"users show", []string{"u1"}, "", false, "GET", "/users/u1", `{"data":{"id":"u1","subject":"sub","email":"user@example.test"}}`, "user@example.test"}, + } + for _, test := range cases { + t.Run(test.path, func(t *testing.T) { testCommand(t, test) }) + } +} +func testCommand(t *testing.T, test commandTest) { + t.Helper() + calls := 0 + res, err := execute(t, request(test.path, test.args, test.body, test.confirm), func(req *http.Request) (*http.Response, error) { calls++; return commandReply(t, test, req), nil }) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("calls=%d", calls) + } + if !json.Valid(res.Data) || !strings.Contains(string(res.Data), test.want) { + t.Fatalf("data=%s", res.Data) + } + if test.path == "clients secrets list" && strings.Contains(string(res.Data), `"data"`) { + t.Fatal("expected a secret array") + } +} +func commandReply(t *testing.T, test commandTest, req *http.Request) *http.Response { + t.Helper() + if req.Method != test.method || req.URL.Path != "/api/auth"+test.url { + t.Fatalf("request=%s %s", req.Method, req.URL) + } + if req.Header.Get("Authorization") != "Bearer injected" { + t.Fatal("lost injected authentication") + } + if test.body != "" { + assertJSON(t, readRequest(t, req), []byte(test.body)) + if req.Header.Get("Content-Type") != "application/json" { + t.Fatal("missing content type") + } + } + code := 200 + if test.path == "clients create" { + code = 201 + } + if test.method == "DELETE" { + code = 204 + } + return reply(code, test.response) +} +func readRequest(t *testing.T, req *http.Request) []byte { + t.Helper() + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + return body +} +func assertJSON(t *testing.T, got, want []byte) { + t.Helper() + a := canonicalJSON(t, got) + b := canonicalJSON(t, want) + if !bytes.Equal(a, b) { + t.Fatalf("JSON=%s; want %s", a, b) + } +} +func canonicalJSON(t *testing.T, raw []byte) []byte { + t.Helper() + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} +func TestUpdatePreservesAndClearsOptions(t *testing.T) { + current := `{"data":{"id":"c1","name":"original","description":"old","public":true,"trusted":true,"redirectUris":["https://example.test/cb"],"postLogoutRedirectUris":["https://example.test/bye"],"scopes":["auth:read"],"metadata":{"env":"test"},"secrets":[]}}` + for _, patch := range []string{`{"description":"changed"}`, `{"public":false,"trusted":false,"redirectUris":[],"postLogoutRedirectUris":[],"scopes":[],"metadata":{}}`} { + t.Run(patch, func(t *testing.T) { + calls := 0 + res, err := execute(t, request("clients update", []string{"c1"}, patch, true), func(req *http.Request) (*http.Response, error) { + calls++ + return updateReply(t, req, calls, current, patch), nil + }) + if err != nil { + t.Fatal(err) + } + if calls != 2 || !strings.Contains(string(res.Data), "original") { + t.Fatalf("calls=%d data=%s", calls, res.Data) + } + }) + } +} +func updateReply(t *testing.T, req *http.Request, calls int, current, patch string) *http.Response { + t.Helper() + if req.URL.Path != "/api/auth/clients/c1" { + t.Fatal(req.URL) + } + if calls == 1 { + if req.Method != "GET" { + t.Fatal(req.Method) + } + return reply(200, current) + } + if req.Method != "PUT" { + t.Fatal(req.Method) + } + checkUpdatedOptions(t, readRequest(t, req), current, patch) + return reply(200, current) +} +func checkUpdatedOptions(t *testing.T, body []byte, current, patch string) { + t.Helper() + var got map[string]json.RawMessage + decode(t, body, &got) + if _, ok := got["id"]; ok { + t.Fatal("sent read-only id") + } + if _, ok := got["secrets"]; ok { + t.Fatal("sent secrets") + } + if string(got["name"]) != `"original"` { + t.Fatal("lost name") + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + decode(t, []byte(current), &envelope) + var changes map[string]json.RawMessage + decode(t, []byte(patch), &changes) + for key, value := range envelope.Data { + if key == "id" || key == "secrets" { + continue + } + if v, ok := changes[key]; ok { + value = v + } + checkOption(t, key, got[key], value) + } +} +func decode(t *testing.T, data []byte, target any) { + t.Helper() + if err := json.Unmarshal(data, target); err != nil { + t.Fatal(err) + } +} +func checkOption(t *testing.T, key string, got, want json.RawMessage) { + t.Helper() + if len(got) == 0 { // Generated omitempty fields decode as empty options on the server. + if string(want) != "[]" && string(want) != "{}" { + t.Fatalf("lost %s", key) + } + return + } + assertJSON(t, got, want) +} + +// The host may authenticate before Execute; this fixture checks service HTTP only. +func TestValidationBeforeServiceRequest(t *testing.T) { + cases := []pluginsdk.ExecuteRequest{ + request("clients delete", []string{"c1"}, "", false), request("clients update", []string{"c1"}, `{"name":"x"}`, false), request("clients secrets delete", []string{"c1", "s1"}, "", false), + request("clients create", nil, `{"unknown":true,"name":"x"}`, false), request("clients create", nil, `null`, false), request("clients create", nil, `{"name":""}`, false), request("clients create", nil, `{"name":null}`, false), + request("clients update", []string{"c1"}, `{"scopes":1}`, true), request("clients secrets create", []string{"c1"}, `{}`, false), request("clients create", nil, "", false), + request("clients show", []string{".."}, "", false), request("users show", []string{""}, "", false), request("users show", []string{"."}, "", false), request("missing", nil, "", false), request("clients", nil, "", false), request("users show", nil, "", false), + } + for _, req := range cases { + t.Run(strings.Join(req.CommandPath, " ")+string(req.Body), func(t *testing.T) { + res, err := execute(t, req, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network request"); return nil, nil }) + if err == nil || len(res.Data) > 0 { + t.Fatalf("data=%s err=%v", res.Data, err) + } + }) + } +} +func TestFlagValidation(t *testing.T) { + for _, flags := range []map[string]string{{"confirm": "invalid"}, {"unknown": "true"}} { + req := request("clients delete", []string{"c1"}, "", true) + req.Flags = flags + _, err := execute(t, req, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network request"); return nil, nil }) + if err == nil { + t.Fatal("expected invalid flag error") + } + } +} +func TestErrorsLeaveDataEmpty(t *testing.T) { + for _, status := range []int{401, 404, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + body := `{"errorCode":"FAIL","errorMessage":"denied","partial":9007199254740993}` + res, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return reply(status, body), nil }) + if err == nil { + t.Fatal("expected error") + } + if len(res.Data) != 0 { + t.Fatalf("error data=%s", res.Data) + } + }) + } + offline := errors.New("offline") + res, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return nil, offline }) + if !errors.Is(err, offline) || res.Data != nil { + t.Fatalf("data=%s err=%v", res.Data, err) + } +} +func TestInvalidResponse(t *testing.T) { + for _, code := range []int{200, 400} { + _, err := execute(t, request("info", nil, "", false), func(*http.Request) (*http.Response, error) { return reply(code, "broken"), nil }) + if err == nil { + t.Fatal("expected JSON error") + } + } +} +func TestEncodedIdentifier(t *testing.T) { + _, err := execute(t, request("clients show", []string{"a/b ?#%"}, "", false), func(req *http.Request) (*http.Response, error) { + if req.URL.EscapedPath() != "/api/auth/clients/a%2Fb%20%3F%23%25" || req.URL.RawQuery != "" { + t.Fatal(req.URL) + } + return reply(200, `{"data":{"id":"c1","name":"test"}}`), nil + }) + if err != nil { + t.Fatal(err) + } +} +func TestUpdateReadFailureDoesNotWrite(t *testing.T) { + for _, body := range []string{`{"errorCode":"NOT_FOUND","errorMessage":"missing"}`, `{}`} { + calls := 0 + _, err := execute(t, request("clients update", []string{"c1"}, `{"description":"changed"}`, true), func(req *http.Request) (*http.Response, error) { + calls++ + if req.Method != "GET" { + t.Fatal("write after failed read") + } + code := 404 + if body == `{}` { + code = 200 + } + return reply(code, body), nil + }) + if err == nil || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + } +} +func TestEmptySecrets(t *testing.T) { + res, err := execute(t, request("clients secrets list", []string{"c1"}, "", false), func(*http.Request) (*http.Response, error) { + return reply(200, `{"data":{"id":"c1","name":"test"}}`), nil + }) + if err != nil { + t.Fatal(err) + } + assertJSON(t, res.Data, []byte(`[]`)) +} +func TestManifest(t *testing.T) { + m, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if m.Name != "auth" || m.Service != "auth" || m.Version == "" || m.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("manifest=%+v", m) + } + update, err := pluginsdk.FindCommand(m, []string{"auth", "clients", "update"}) + if err != nil { + t.Fatal(err) + } + if !update.Confirm || !strings.Contains(update.Long, "preserved") { + t.Fatal("update contract missing") + } + create, err := pluginsdk.FindCommand(m, []string{"auth", "clients", "create"}) + if err != nil { + t.Fatal(err) + } + if len(create.Flags) != 1 || !create.Flags[0].Required || !create.Flags[0].Body || create.Flags[0].Type != "string" { + t.Fatal("body contract missing") + } + // Each call returns an independent manifest, safe for concurrent host adapters. + m.Root.Subcommands[0].Use = "changed" + fresh, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if fresh.Root.Subcommands[0].Use != "info" { + t.Fatal("shared manifest state") + } +} +func TestConnectionValidation(t *testing.T) { + _, err := New(nil).Execute(t.Context(), request("info", nil, "", false)) + if err == nil { + t.Fatal("expected missing client error") + } + req := request("info", nil, "", false) + req.Endpoint = "https://user:secret@example.test" + _, err = New(&http.Client{}).Execute(t.Context(), req) + if err == nil { + t.Fatal("expected endpoint error") + } +} + +func TestManifestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + m, err := New(nil).GetManifest(ctx) + if !errors.Is(err, context.Canceled) || m.Name != "" { + t.Fatalf("manifest=%+v err=%v", m, err) + } +} +func TestProvidedBodyWithoutDataFlag(t *testing.T) { + req := request("clients create", nil, `{"name":"direct"}`, false) + req.Flags = nil + req.ChangedFlags = nil + res, err := execute(t, req, func(r *http.Request) (*http.Response, error) { + if r.Method != "POST" || r.URL.Path != "/api/auth/clients" { + t.Fatalf("request=%s %s", r.Method, r.URL) + } + assertJSON(t, readRequest(t, r), []byte(`{"name":"direct"}`)) + return reply(201, `{"data":{"id":"c1","name":"direct"}}`), nil + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(res.Data), "direct") { + t.Fatal(string(res.Data)) + } +} + +type responseFixture struct{ metadata components.HTTPMetadata } + +func (r responseFixture) GetHTTPMeta() components.HTTPMetadata { return r.metadata } + +type unreadBody struct{ touched bool } + +func (b *unreadBody) Read([]byte) (int, error) { + b.touched = true + return 0, errors.New("response must not be read") +} +func (b *unreadBody) Close() error { + b.touched = true + return errors.New("response must not be processed") +} +func TestResultErrorDoesNotProcessResponse(t *testing.T) { + body := &unreadBody{} + failure := errors.New("operation failed") + res := responseFixture{metadata: components.HTTPMetadata{Response: &http.Response{StatusCode: 400, Body: body}}} + data, err := result(res, failure) + if len(data) != 0 || !errors.Is(err, failure) || body.touched { + t.Fatalf("data=%s err=%v response touched=%v", data, err, body.touched) + } +} + +func TestResultMissingResponse(t *testing.T) { + var absent *responseFixture + for _, res := range []response{nil, absent} { + data, err := result(res, nil) + if len(data) != 0 || err == nil { + t.Fatalf("missing response: data=%s err=%v", data, err) + } + } +} diff --git a/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go b/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go new file mode 100644 index 0000000..1027e8c --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go @@ -0,0 +1,86 @@ +// fctl-plugin-auth serves Auth commands through the public fctl protocol. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strconv" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +// GoReleaser supplies both values for each immutable product release artifact. +var ( + serviceVersion = "dev" + revision = "1" +) + +type versionMetadata struct { + Name string `json:"name"` + ServiceVersion string `json:"serviceVersion"` + Revision int `json:"revision"` + ProtocolVersion int `json:"protocolVersion"` +} + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, output, diagnostics io.Writer) error { + flags := flag.NewFlagSet("fctl-plugin-auth", flag.ContinueOnError) + flags.SetOutput(diagnostics) + manifest := flags.Bool("manifest", false, "Print the SDK command manifest as JSON without starting the plugin") + version := flags.Bool("version", false, "Print the service version and plugin revision as JSON") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %v", flags.Args()) + } + if *manifest && *version { + return errors.New("use either --manifest or --version") + } + pluginRevision, err := strconv.Atoi(revision) + if err != nil || pluginRevision < 1 { + return errors.New("plugin revision must be a positive integer") + } + if serviceVersion == "" { + return errors.New("auth service version must not be empty") + } + factory := func(client *http.Client) pluginsdk.Plugin { + return auth.NewVersion(client, serviceVersion) + } + if *manifest { + metadata, err := factory(nil).GetManifest(context.Background()) + if err != nil { + return err + } + + return json.NewEncoder(output).Encode(metadata) + } + if *version { + return json.NewEncoder(output).Encode(versionMetadata{ + Name: "auth", ServiceVersion: serviceVersion, + Revision: pluginRevision, ProtocolVersion: pluginsdk.ProtocolVersion, + }) + } + transport.Serve(factory) + + return nil +} diff --git a/misc/fctl-plugin/go.mod b/misc/fctl-plugin/go.mod new file mode 100644 index 0000000..273ba64 --- /dev/null +++ b/misc/fctl-plugin/go.mod @@ -0,0 +1,27 @@ +module github.com/formancehq/auth/misc/fctl-plugin + +go 1.26.0 + +require ( + github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba + github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261009101655-a5cfb893d64d +) + +require ( + github.com/cenkalti/backoff/v4 v4.2.0 // indirect + github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.8.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/misc/fctl-plugin/go.sum b/misc/fctl-plugin/go.sum new file mode 100644 index 0000000..85b4205 --- /dev/null +++ b/misc/fctl-plugin/go.sum @@ -0,0 +1,64 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= +github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuLLZsyM5ybUzgc/mPjIYk2AZqufieooe98uw= +github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba h1:UlEHshELSyVGhLQamC6zSXLzOvKW/8zAGndNIRa9THY= +github.com/formancehq/auth/pkg/client v0.0.0-20251106135031-5373fa4eaeba/go.mod h1:7w74DBUsVrg9FuGsogUMsmBCjGGloEKUEyvZGsNuUeg= +github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261009101655-a5cfb893d64d h1:mohqPs8upfatkGPRVHTaqYGUqQdioeN7KjF2O6qTBs4= +github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261009101655-a5cfb893d64d/go.mod h1:u465cjRJPe7PdlPwMk4b7QiyMqmu1rdJARu8LobuQgA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/misc/fctl-plugin/interaction.go b/misc/fctl-plugin/interaction.go new file mode 100644 index 0000000..fc676a7 --- /dev/null +++ b/misc/fctl-plugin/interaction.go @@ -0,0 +1,74 @@ +package auth + +import ( + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +// Inputs belong to leaves. The host resolves stack context and runs these +// ordinary read commands through the plugin's injected client. +func addInputs(command *pluginsdk.CommandSpec, parent []string) { + path := append(append([]string{}, parent...), pluginsdk.CommandName(*command)) + if command.Runnable { + command.Inputs = commandInputs(strings.Join(path, " ")) + } + for i := range command.Subcommands { + addInputs(&command.Subcommands[i], path) + } +} + +func commandInputs(path string) []pluginsdk.InputSpec { + switch path { + case "auth clients create": + return clientCreateInputs() + case "auth clients show", "auth clients delete", "auth clients secrets list": + return []pluginsdk.InputSpec{clientInput()} + case "auth clients update": + // Do not add default booleans, arrays or objects to an update patch. + // Existing options are merged by updateClient. Explicit --data patches + // also let callers update any option without requiring a new name. + return []pluginsdk.InputSpec{ + clientInput(), + {Title: "Client name", Description: "New name for the client", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, + {Title: "Description", Description: "Leave blank to preserve the current description", Kind: "input", BodyPointer: "/description", ValueType: "string"}, + } + case "auth clients secrets create": + return []pluginsdk.InputSpec{ + clientInput(), + {Title: "Secret name", Description: "Label for the new secret; its clear value is generated by Auth", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, + {Title: "Metadata", Description: `Optional JSON object of string values, e.g. {"purpose":"ci"}`, Kind: "text", BodyPointer: "/metadata", ValueType: "json"}, + } + case "auth clients secrets delete": + return []pluginsdk.InputSpec{ + clientInput(), + {Title: "Client secret", Kind: "select", Argument: new(1), ValueType: "string", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "clients", "secrets", "list"}, Args: []string{"$arg0"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "This client has no secrets to delete"}}, + } + case "auth users show": + return []pluginsdk.InputSpec{ + {Title: "User", Kind: "select", Argument: new(0), ValueType: "string", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "users", "list"}, ValueField: "id", LabelFields: []string{"email", "subject", "id"}, EmptyMessage: "No Auth users are available"}}, + } + default: + return nil + } +} + +func clientInput() pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: "OAuth2 client", Kind: "select", Argument: new(0), ValueType: "string", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "clients", "list"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "No OAuth2 clients are available; create a client first"}} +} + +func clientCreateInputs() []pluginsdk.InputSpec { + return []pluginsdk.InputSpec{ + {Title: "Client name", Kind: "input", BodyPointer: "/name", ValueType: "string", Required: true}, + {Title: "Description", Kind: "input", BodyPointer: "/description", ValueType: "string"}, + {Title: "Public client", Description: "Public clients cannot keep a client secret", Kind: "confirm", BodyPointer: "/public", ValueType: "bool", Default: "false"}, + {Title: "Trusted client", Description: "Trusted clients bypass user consent", Kind: "confirm", BodyPointer: "/trusted", ValueType: "bool", Default: "false"}, + {Title: "Scopes", Description: `JSON array of scope strings, e.g. ["ledger:read"]`, Kind: "text", BodyPointer: "/scopes", ValueType: "json"}, + {Title: "Redirect URIs", Description: `JSON array of URI strings, e.g. ["https://example.test/callback"]`, Kind: "text", BodyPointer: "/redirectUris", ValueType: "json"}, + {Title: "Post-logout redirect URIs", Description: "Optional JSON array of URI strings", Kind: "text", BodyPointer: "/postLogoutRedirectUris", ValueType: "json"}, + {Title: "Metadata", Description: `Optional JSON object of string values, e.g. {"purpose":"automation"}`, Kind: "text", BodyPointer: "/metadata", ValueType: "json"}, + } +} diff --git a/misc/fctl-plugin/interaction_test.go b/misc/fctl-plugin/interaction_test.go new file mode 100644 index 0000000..4c5b817 --- /dev/null +++ b/misc/fctl-plugin/interaction_test.go @@ -0,0 +1,222 @@ +package auth + +import ( + "encoding/json" + "net/http" + "reflect" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestInteractionManifestContract(t *testing.T) { + t.Parallel() + m := manifest() + if m.Root.Target != "stack" { + t.Fatal("Auth must target stack context") + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var decoded pluginsdk.Manifest + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(m, decoded) { + t.Fatal("input metadata does not survive serialization") + } + walkInputs(t, m, m.Root, nil) +} + +func walkInputs(t *testing.T, m pluginsdk.Manifest, command pluginsdk.CommandSpec, parent []string) { + t.Helper() + path := append(append([]string{}, parent...), pluginsdk.CommandName(command)) + if !command.Runnable && len(command.Inputs) != 0 { + t.Fatalf("%s has inherited inputs", path) + } + for _, input := range command.Inputs { + checkInputBinding(t, command, input) + if input.Source != nil { + source, err := pluginsdk.FindCommand(m, input.Source.CommandPath) + if err != nil || !source.Runnable || source.Confirm { + t.Fatalf("%s has invalid source: %#v, %v", path, input.Source, err) + } + if len(input.Source.Args) != source.Args.Min || input.Source.ValueField == "" || input.Source.EmptyMessage == "" { + t.Fatalf("incomplete source: %#v", input.Source) + } + } + } + for _, child := range command.Subcommands { + walkInputs(t, m, child, path) + } +} + +func checkInputBinding(t *testing.T, command pluginsdk.CommandSpec, input pluginsdk.InputSpec) { + t.Helper() + bindings := 0 + for _, bound := range []bool{input.Flag != "", input.Argument != nil, input.BodyPointer != "", input.Context != ""} { + if bound { + bindings++ + } + } + if bindings != 1 || input.Title == "" || !slices.Contains([]string{"input", "text", "select", "confirm"}, input.Kind) || !slices.Contains([]string{"", "string", "json", "bool", "number"}, input.ValueType) { + t.Fatalf("invalid input: %#v", input) + } + if input.Argument != nil && (*input.Argument < 0 || *input.Argument >= command.Args.Max) { + t.Fatalf("argument binding out of range: %#v", input) + } + if input.BodyPointer != "" { + checkBodyPointer(t, input.BodyPointer) + } +} + +func checkBodyPointer(t *testing.T, pointer string) { + t.Helper() + if !strings.HasPrefix(pointer, "/") { + t.Fatalf("invalid JSON pointer %q", pointer) + } + for i := 0; i < len(pointer); i++ { + if pointer[i] == '~' { + i++ + if i >= len(pointer) || (pointer[i] != '0' && pointer[i] != '1') { + t.Fatalf("invalid JSON pointer escape %q", pointer) + } + } + } +} + +func TestAuthCreateBodyInputsMatchSDK(t *testing.T) { + t.Parallel() + command, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth clients create")) + if err != nil { + t.Fatal(err) + } + values := map[string]string{ + "/name": `"automation"`, "/description": `"Service client"`, "/public": `false`, "/trusted": `false`, + "/scopes": `["ledger:read"]`, "/redirectUris": `["https://example.test/callback"]`, + "/postLogoutRedirectUris": `["https://example.test/logout"]`, "/metadata": `{"purpose":"tests"}`, + } + body := make(map[string]json.RawMessage) + for _, input := range command.Inputs { + value, ok := values[input.BodyPointer] + if !ok { + t.Fatalf("unknown SDK field %q", input.BodyPointer) + } + body[strings.TrimPrefix(input.BodyPointer, "/")] = json.RawMessage(value) + } + if len(body) != len(values) || !command.Inputs[0].Required { + t.Fatal("incomplete client creation form") + } + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + _, err = execute(t, request("clients create", nil, string(data), false), func(req *http.Request) (*http.Response, error) { + assertJSON(t, readRequest(t, req), data) + return reply(http.StatusCreated, `{"data":{"id":"c1","name":"automation"}}`), nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAuthUpdateInputsPreserveOmittedOptions(t *testing.T) { + t.Parallel() + command, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth clients update")) + if err != nil { + t.Fatal(err) + } + for _, input := range command.Inputs { + if input.BodyPointer == "" { + continue + } + if !slices.Contains([]string{"/name", "/description"}, input.BodyPointer) || input.Default != "" || input.Kind == "confirm" { + t.Fatalf("update injects an omitted option: %#v", input) + } + if input.BodyPointer == "/description" && input.Required { + t.Fatal("description must be optional") + } + } + current := `{"data":{"name":"old","description":"preserved","public":true,"trusted":true,"scopes":["ledger:read"],"metadata":{"purpose":"original"}}}` + patch := `{"name":"renamed"}` + merged, err := mergeOptions(json.RawMessage(current), json.RawMessage(patch)) + if err != nil { + t.Fatal(err) + } + assertJSON(t, merged, []byte(`{"name":"renamed","description":"preserved","public":true,"trusted":true,"scopes":["ledger:read"],"metadata":{"purpose":"original"}}`)) +} + +func TestAuthChoiceSourcesUseInjectedReadClient(t *testing.T) { + t.Parallel() + cases := []struct { + command, field, source, response, value string + argument int + }{ + {"clients show", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, + {"clients delete", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, + {"clients update", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, + {"clients secrets list", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, + {"clients secrets create", "id", "clients list", `{"data":[{"id":"c1","name":"Automation"}]}`, "c1", 0}, + {"clients secrets delete", "id", "clients secrets list", `{"data":{"id":"c1","name":"Automation","secrets":[{"id":"s1","name":"CI"}]}}`, "s1", 1}, + {"users show", "id", "users list", `{"data":[{"id":"u1","subject":"sub","email":"user@example.test"}]}`, "u1", 0}, + } + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + checkAuthChoiceSource(t, tc.command, tc.argument, tc.source, tc.response, tc.field, tc.value) + }) + } +} + +func checkAuthChoiceSource(t *testing.T, command string, argument int, sourcePath, response, field, value string) { + t.Helper() + spec, err := pluginsdk.FindCommand(manifest(), strings.Fields("auth "+command)) + if err != nil { + t.Fatal(err) + } + index := slices.IndexFunc(spec.Inputs, func(input pluginsdk.InputSpec) bool { return input.Argument != nil && *input.Argument == argument }) + if index < 0 || spec.Inputs[index].Source == nil { + t.Fatal("missing selector") + } + source := spec.Inputs[index].Source + if strings.Join(source.CommandPath, " ") != "auth "+sourcePath || source.ValueField != field { + t.Fatalf("source = %#v", source) + } + args := slices.Clone(source.Args) + for i, arg := range args { + if arg != "$arg0" { + t.Fatalf("unexpected substitution %q", arg) + } + args[i] = "c1" + } + calls := 0 + result, err := execute(t, pluginsdk.ExecuteRequest{CommandPath: source.CommandPath, Args: args}, func(req *http.Request) (*http.Response, error) { + calls++ + if req.Method != http.MethodGet { + t.Fatalf("choice source attempted %s", req.Method) + } + return reply(http.StatusOK, response), nil + }) + if err != nil || calls != 1 { + t.Fatalf("source execution: calls=%d, error=%v", calls, err) + } + rows := authChoiceRows(t, result.Data) + if len(rows) != 1 || rows[0][field] != value { + t.Fatalf("selector cannot read %s: %s", field, result.Data) + } +} + +func authChoiceRows(t *testing.T, data json.RawMessage) []map[string]string { + t.Helper() + var envelope struct { + Data []map[string]string `json:"data"` + } + if strings.HasPrefix(string(data), "[") { + decode(t, data, &envelope.Data) + } else { + decode(t, data, &envelope) + } + return envelope.Data +} diff --git a/misc/fctl-plugin/manifest.go b/misc/fctl-plugin/manifest.go new file mode 100644 index 0000000..56c9026 --- /dev/null +++ b/misc/fctl-plugin/manifest.go @@ -0,0 +1,50 @@ +package auth + +import "github.com/formancehq/fctl/pkg/pluginsdk" + +func manifest() pluginsdk.Manifest { + m := pluginsdk.Manifest{ + Name: "auth", Version: defaultServiceVersion, Service: "auth", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "auth", Target: "stack", Short: "Manage Auth clients, secrets and users", Subcommands: []pluginsdk.CommandSpec{ + leaf("info", "Show Auth server information", 0, false), + leaf("discovery", "Show OpenID Connect discovery configuration", 0, false), + {Use: "clients", Short: "Manage OAuth2 clients", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List OAuth2 clients", 0, false), + leaf("show CLIENT", "Show a client and its secret metadata", 1, false), + bodyCommand("create", "Create an OAuth2 client", 0, false, false), + bodyCommand("update CLIENT", "Update client options, preserving omitted fields", 1, true, false), + leaf("delete CLIENT", "Delete an OAuth2 client", 1, true), + {Use: "secrets", Short: "Manage client secrets (clear values are returned only at creation)", Subcommands: []pluginsdk.CommandSpec{ + leaf("list CLIENT", "List secret metadata for a client", 1, false), + bodyCommand("create CLIENT", "Create a secret; save the returned clear value", 1, false, true), + leaf("delete CLIENT SECRET", "Delete a client secret", 2, true), + }}, + }}, + {Use: "users", Short: "Read Auth users", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List users", 0, false), leaf("show USER", "Show a user", 1, false), + }}, + }}, + } + addInputs(&m.Root, nil) + return m +} +func leaf(use, short string, args int, confirm bool) pluginsdk.CommandSpec { + spec := pluginsdk.CommandSpec{Use: use, Short: short, Args: pluginsdk.ArgsSpec{Min: args, Max: args}, Runnable: true, Confirm: confirm} + if confirm { + spec.Flags = []pluginsdk.FlagSpec{{Name: "confirm", Type: "bool", Default: "false", Usage: "Confirm this destructive operation"}} + } + return spec +} +func bodyCommand(use, short string, args int, confirm, secret bool) pluginsdk.CommandSpec { + spec := leaf(use, short, args, confirm) + spec.Flags = append(spec.Flags, pluginsdk.FlagSpec{Name: "data", Type: "string", Usage: "JSON object; inline, @file, or - for stdin", Required: true, Body: true}) + spec.Long = short + ".\nOptions: name, description, public, trusted, redirectUris, postLogoutRedirectUris, scopes, metadata." + if secret { + spec.Long = short + ".\nJSON object with required name and optional metadata." + } else if confirm { + spec.Long += "\nOmitted options are preserved. Explicit false, empty arrays and empty objects clear options. Requires --confirm." + } else { + spec.Long += "\nThe name field is required. Create a secret separately with clients secrets create." + } + return spec +} diff --git a/misc/fctl-plugin/payload.go b/misc/fctl-plugin/payload.go new file mode 100644 index 0000000..c0ab199 --- /dev/null +++ b/misc/fctl-plugin/payload.go @@ -0,0 +1,113 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "reflect" + "strings" +) + +func decodeObject(data json.RawMessage, target any) error { + if len(bytes.TrimSpace(data)) == 0 || bytes.TrimSpace(data)[0] != '{' { + return fmt.Errorf("--data must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("invalid --data: %w", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for key, value := range fields { + if key == "name" { + var name string + if err := json.Unmarshal(value, &name); err != nil || strings.TrimSpace(name) == "" { + return fmt.Errorf("--data requires a non-empty name") + } + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("--data field %s cannot be null; use false, an empty string, array or object", key) + } + } + return nil +} +func responseBody(res response) (json.RawMessage, error) { + if res == nil || (reflect.ValueOf(res).Kind() == reflect.Pointer && reflect.ValueOf(res).IsNil()) { + return nil, fmt.Errorf("auth SDK returned no response") + } + httpRes := res.GetHTTPMeta().Response + if httpRes == nil { + return nil, fmt.Errorf("auth SDK returned no HTTP response") + } + if httpRes.Body == nil { + return json.RawMessage("null"), nil + } + body, err := io.ReadAll(io.LimitReader(httpRes.Body, 32<<20+1)) + closeErr := httpRes.Body.Close() + if err != nil { + return nil, fmt.Errorf("read Auth response: %w", err) + } + if closeErr != nil { + return nil, fmt.Errorf("close Auth response: %w", closeErr) + } + if len(body) > 32<<20 { + return nil, fmt.Errorf("auth response exceeds 32 MiB") + } + if httpRes.StatusCode < http.StatusOK || httpRes.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("auth HTTP %d", httpRes.StatusCode) + } + if len(bytes.TrimSpace(body)) == 0 { + return json.RawMessage("null"), nil + } + if !json.Valid(body) { + return nil, fmt.Errorf("auth returned invalid JSON") + } + return body, nil +} +func mergeOptions(current, patch json.RawMessage) (json.RawMessage, error) { + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(current, &envelope); err != nil { + return nil, err + } + if envelope.Data == nil { + return nil, fmt.Errorf("auth response is missing client data") + } + fields := make(map[string]json.RawMessage) + for _, key := range []string{"name", "description", "public", "trusted", "redirectUris", "postLogoutRedirectUris", "scopes", "metadata"} { + if value, ok := envelope.Data[key]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + fields[key] = value + } + } + var changes map[string]json.RawMessage + if err := json.Unmarshal(patch, &changes); err != nil { + return nil, err + } + maps.Copy(fields, changes) + return json.Marshal(fields) +} + +func secretList(body json.RawMessage) (json.RawMessage, error) { + var envelope struct { + Data *struct { + Secrets json.RawMessage `json:"secrets"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, err + } + if envelope.Data == nil { + return nil, fmt.Errorf("auth response is missing client data") + } + if len(envelope.Data.Secrets) == 0 || bytes.Equal(envelope.Data.Secrets, []byte("null")) { + return json.RawMessage("[]"), nil + } + return envelope.Data.Secrets, nil +} From a61be6de1b81233758c14da7c01f8f49e0b9fc70 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:10:20 +0200 Subject: [PATCH 2/4] test(fctl-plugin): verify Auth public API and executable contract --- misc/fctl-plugin/.gitignore | 1 + misc/fctl-plugin/README.md | 82 +++++++++ .../cmd/fctl-plugin-auth/executable_test.go | 134 ++++++++++++++ misc/fctl-plugin/cmd/fctl-plugin-auth/main.go | 3 +- .../cmd/fctl-plugin-auth/main_test.go | 73 ++++++++ misc/fctl-plugin/contract_test.go | 164 ++++++++++++++++++ 6 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 misc/fctl-plugin/.gitignore create mode 100644 misc/fctl-plugin/README.md create mode 100644 misc/fctl-plugin/cmd/fctl-plugin-auth/executable_test.go create mode 100644 misc/fctl-plugin/cmd/fctl-plugin-auth/main_test.go create mode 100644 misc/fctl-plugin/contract_test.go diff --git a/misc/fctl-plugin/.gitignore b/misc/fctl-plugin/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/misc/fctl-plugin/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/misc/fctl-plugin/README.md b/misc/fctl-plugin/README.md new file mode 100644 index 0000000..c4ace34 --- /dev/null +++ b/misc/fctl-plugin/README.md @@ -0,0 +1,82 @@ +# Auth fctl plugin + +Profiles: **library** and **CLI**. This independent Go 1.26 module provides the +Auth command contract, declarative forms and HTTP execution for fctl. Its only +direct dependencies are the public `github.com/formancehq/fctl/pkg/pluginsdk` +module and `github.com/formancehq/auth/pkg/client`, the generated Auth client. +It does not depend on the fctl core or Auth server packages. + +## Integration API + +Import `github.com/formancehq/auth/misc/fctl-plugin` as `auth`: + +```go +plugin := auth.NewVersion(authenticatedHTTPClient, "2.5.0-beta.1") +manifest, err := plugin.GetManifest(ctx) +response, err := plugin.Execute(ctx, pluginsdk.ExecuteRequest{ + CommandPath: []string{"auth", "clients", "create"}, + Endpoint: "https://example.test/api/auth", + Body: json.RawMessage(`{"name":"automation"}`), +}) +``` + +- `New(*http.Client) pluginsdk.Plugin` uses the development version `dev`. +- `NewVersion(*http.Client, string) pluginsdk.Plugin` puts the exact service + version in the manifest. An empty version falls back to `dev`. +- A nil HTTP client supports offline metadata inspection. Execution requires + an injected client; the plugin does not fall back to a global HTTP client. +- Each instance owns its version and returns fresh manifests. Manifest `name` + and `service` are `auth`, the root targets `stack`, and the protocol version + comes from `pluginsdk.ProtocolVersion`. + +The host owns authentication, endpoint selection, file/stdin input, forms, +confirmation prompts and output rendering. Direct callers supply the JSON body +and `confirm: "true"` for destructive operations. Explicit false values and +empty collections in update patches clear options; omitted options are read +and preserved. The plugin validates patches before that read. Cancellation +before execution makes no HTTP request; cancellation during an update prevents +the subsequent write. Operations are not retried by the plugin. + +The manifest retains all 12 commands: server info, OIDC discovery, client list, +show, create, update and delete, secret list, create and delete, and user list +and show. All creation forms and client, secret and user selectors remain +serializable SDK metadata. Responses preserve their original JSON bytes and +number precision; Auth errors return no response data. Secret creation returns +the one-time clear value; secret listing returns metadata only. + +## Standalone executable + +Run from this module directory: + +```sh +GOWORK=off go build -o build/fctl-plugin-auth \ + -ldflags '-X main.serviceVersion=2.5.0-beta.1 -X main.revision=1' \ + ./cmd/fctl-plugin-auth +./build/fctl-plugin-auth --manifest +./build/fctl-plugin-auth --version +``` + +The service version must match the target Auth service exactly. Revision is a +separate positive integer for plugin-only changes; it is never appended to the +manifest version. Local builds default to service version `dev` and revision +`1`. Without metadata flags, the executable calls `transport.Serve` and uses +the host's HTTP broker. It has no independent credentials or terminal command +tree. Standard `flag` parsing replaces Cobra for these two metadata flags; +the executable has no service lifecycle requiring Fx. Owner: Auth maintainers. +Revisit this exception if independent user commands or listeners are added. + +## Validation + +```sh +GOWORK=off go test -race -cover ./... +GOWORK=off go vet ./... +``` + +The complete original Auth fixture suite is retained. Public consumer tests +cover exact version metadata, independent manifests, serialization, validation +before HTTP and cancellation. An executable fixture builds a versioned binary, +reads both metadata flags, starts it through the public SDK transport, and +checks the host authentication broker, gateway prefix, creation form, exact +JSON and rejected payloads. These are local fixtures, not live Auth validation. +The fctl loader, host UI and distribution pipeline are maintained separately. +Root-module `go test ./...` does not discover this nested module. diff --git a/misc/fctl-plugin/cmd/fctl-plugin-auth/executable_test.go b/misc/fctl-plugin/cmd/fctl-plugin-auth/executable_test.go new file mode 100644 index 0000000..8e79e46 --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-auth/executable_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) + +type authenticatedTransport struct{ base http.RoundTripper } + +func (t authenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + r.Header.Set("Authorization", "Bearer synthetic-host-token") + return t.base.RoundTrip(r) +} + +func TestStandaloneExecutableUsesHostHTTPBroker(t *testing.T) { + t.Parallel() + binary := buildFixtureBinary(t) + checkFixtureMetadata(t, binary) + const payload = `{"name":"automation","public":false,"metadata":{"purpose":"fixture"}}` + const result = `{ "data": { "id": "c1", "name": "automation", "extra": 9007199254740993 } }` + var calls atomic.Int32 + server := httptest.NewServer(fixtureHandler(t, &calls, payload, result)) + defer server.Close() + client := &http.Client{Transport: authenticatedTransport{base: http.DefaultTransport}} + p, err := transport.Open(t.Context(), binary, client, server.URL+"/api/auth") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := p.Close(); err != nil { + t.Error(err) + } + }() + m, err := p.GetManifest(t.Context()) + if err != nil || m.Version != "2.5.0-beta.1" || m.Root.Target != "stack" { + t.Fatalf("transport manifest=%+v, err=%v", m, err) + } + command, err := pluginsdk.FindCommand(m, []string{"auth", "clients", "create"}) + if err != nil || len(command.Inputs) != 8 { + t.Fatalf("transport form=%+v, err=%v", command.Inputs, err) + } + req := pluginsdk.ExecuteRequest{CommandPath: []string{"auth", "clients", "create"}, Body: json.RawMessage(payload), Endpoint: server.URL + "/api/auth"} + response, err := p.Execute(t.Context(), req) + if err != nil || string(response.Data) != result || calls.Load() != 1 { + t.Fatalf("standalone calls=%d result=%s, err=%v", calls.Load(), response.Data, err) + } + req.Body = json.RawMessage(`{"name":""}`) + response, err = p.Execute(t.Context(), req) + if err == nil || len(response.Data) != 0 || calls.Load() != 1 { + t.Fatalf("invalid payload reached broker: calls=%d, err=%v", calls.Load(), err) + } +} + +func buildFixtureBinary(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), "fctl-plugin-auth") + // All arguments are fixed by this test; only the owned output directory varies. + //nolint:gosec // G204: this builds trusted product code into a test-owned temporary directory. + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, + "-ldflags", "-X main.serviceVersion=2.5.0-beta.1 -X main.revision=2", ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build standalone executable: %s, %v", output, err) + } + return binary +} + +func checkFixtureMetadata(t *testing.T, binary string) { + t.Helper() + manifest := fixtureMetadata(t, binary, "--manifest") + var m pluginsdk.Manifest + if err := json.Unmarshal(manifest, &m); err != nil || m.Version != "2.5.0-beta.1" || m.Name != "auth" || m.Service != "auth" || m.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("binary manifest=%s, err=%v", manifest, err) + } + checkFixtureVersion(t, fixtureMetadata(t, binary, "--version")) +} + +func checkFixtureVersion(t *testing.T, output []byte) { + t.Helper() + var m versionMetadata + if err := json.Unmarshal(output, &m); err != nil || m.ServiceVersion != "2.5.0-beta.1" || m.Revision != 2 || m.Name != "auth" || m.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("binary version=%s, err=%v", output, err) + } +} + +func fixtureMetadata(t *testing.T, binary, flag string) []byte { + t.Helper() + //nolint:gosec // G204: the executable was built by this test and flags are fixed metadata options. + output, err := exec.CommandContext(t.Context(), binary, flag).Output() + if err != nil { + t.Fatal(err) + } + return output +} + +func fixtureHandler(t *testing.T, calls *atomic.Int32, payload, result string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + calls.Add(1) + if req.Header.Get("Authorization") != "Bearer synthetic-host-token" { + t.Error("host authentication was not applied") + } + if req.Method != http.MethodPost || req.URL.Path != "/api/auth/clients" || req.Header.Get("Content-Type") != "application/json" { + t.Errorf("unexpected request: %s %s", req.Method, req.URL) + } + checkFixturePayload(t, req, payload) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if _, err := io.WriteString(w, result); err != nil { + t.Error(err) + } + }) +} + +func checkFixturePayload(t *testing.T, req *http.Request, payload string) { + t.Helper() + body, err := io.ReadAll(req.Body) + var got, want map[string]any + decodeErr := json.Unmarshal(body, &got) + wantErr := json.Unmarshal([]byte(payload), &want) + if err != nil || decodeErr != nil || wantErr != nil || !reflect.DeepEqual(got, want) { + t.Errorf("request changed: %s, read=%v decode=%v expected=%v", body, err, decodeErr, wantErr) + } +} diff --git a/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go b/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go index 1027e8c..3e189dd 100644 --- a/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go +++ b/misc/fctl-plugin/cmd/fctl-plugin-auth/main.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "io" + "log" "net/http" "os" "strconv" @@ -33,7 +34,7 @@ type versionMetadata struct { func main() { if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) + log.New(os.Stderr, "", 0).Print(err) os.Exit(1) } } diff --git a/misc/fctl-plugin/cmd/fctl-plugin-auth/main_test.go b/misc/fctl-plugin/cmd/fctl-plugin-auth/main_test.go new file mode 100644 index 0000000..6426cf5 --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-auth/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestManifestFlag(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := run([]string{"--manifest"}, &output, io.Discard); err != nil { + t.Fatal(err) + } + var manifest pluginsdk.Manifest + if err := json.Unmarshal(output.Bytes(), &manifest); err != nil { + t.Fatal(err) + } + if manifest.Name != "auth" || manifest.Version != serviceVersion || manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("unexpected manifest: %#v", manifest) + } + if _, err := pluginsdk.FindCommand(manifest, []string{"auth", "clients", "create"}); err != nil { + t.Fatal(err) + } +} + +func TestVersionFlag(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := run([]string{"--version"}, &output, io.Discard); err != nil { + t.Fatal(err) + } + var version versionMetadata + if err := json.Unmarshal(output.Bytes(), &version); err != nil { + t.Fatal(err) + } + if version.Name != "auth" || version.ServiceVersion != serviceVersion || version.Revision != 1 || version.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("unexpected version metadata: %#v", version) + } +} + +func TestInvalidMetadataArguments(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--manifest", "--version"}, {"--manifest", "extra"}, {"--unknown"}} { + if err := run(args, io.Discard, io.Discard); err == nil { + t.Fatalf("accepted invalid arguments: %v", args) + } + } + if err := run([]string{"--help"}, io.Discard, io.Discard); err != nil { + t.Fatal(err) + } +} + +func TestInvalidBuildMetadata(t *testing.T) { + oldVersion, oldRevision := serviceVersion, revision + t.Cleanup(func() { serviceVersion, revision = oldVersion, oldRevision }) + for _, value := range []string{"", "0", "-1", "invalid"} { + revision = value + var output bytes.Buffer + if err := run([]string{"--version"}, &output, io.Discard); err == nil || output.Len() != 0 { + t.Fatalf("accepted revision %q: output=%s err=%v", value, output.String(), err) + } + } + revision = "1" + serviceVersion = "" + var output bytes.Buffer + if err := run([]string{"--manifest"}, &output, io.Discard); err == nil || output.Len() != 0 { + t.Fatalf("accepted empty service version: output=%s err=%v", output.String(), err) + } +} diff --git a/misc/fctl-plugin/contract_test.go b/misc/fctl-plugin/contract_test.go new file mode 100644 index 0000000..fa3a990 --- /dev/null +++ b/misc/fctl-plugin/contract_test.go @@ -0,0 +1,164 @@ +package auth_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestPublicVersionContract(t *testing.T) { + t.Parallel() + for _, version := range []string{"2.5.0", "2.5.0-beta.1+build.42", "dev", ""} { + t.Run(version, func(t *testing.T) { + t.Parallel() + p := auth.NewVersion(nil, version) + m, err := p.GetManifest(t.Context()) + if version == "" { + version = "dev" + } + if err != nil { + t.Fatal(err) + } + checkPublicManifest(t, m, version) + m.Root.Subcommands[0].Use = "mutated" + fresh, err := p.GetManifest(t.Context()) + if err != nil || fresh.Root.Subcommands[0].Use != "info" || fresh.Version != version { + t.Fatalf("instance manifest changed: %+v, %v", fresh, err) + } + }) + } + m, err := auth.New(nil).GetManifest(t.Context()) + if err != nil || m.Version != "dev" { + t.Fatalf("default manifest=%+v, err=%v", m, err) + } +} + +func checkPublicManifest(t *testing.T, m pluginsdk.Manifest, version string) { + t.Helper() + if m.Name != "auth" || m.Service != "auth" || m.Version != version || m.ProtocolVersion != pluginsdk.ProtocolVersion || m.Root.Use != "auth" || m.Root.Target != "stack" { + t.Fatalf("manifest=%+v", m) + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var decoded pluginsdk.Manifest + if err := json.Unmarshal(data, &decoded); err != nil || !reflect.DeepEqual(m, decoded) { + t.Fatalf("metadata round trip: %v", err) + } +} + +func TestPublicValidationBeforeNetwork(t *testing.T) { + t.Parallel() + cases := []struct { + path string + args []string + body string + }{ + {"clients update", []string{"c1"}, `{"name":""}`}, + {"clients update", []string{"c1"}, `{"name":" "}`}, + {"clients update", []string{"c1"}, `{"name":null}`}, + {"clients update", []string{"c1"}, `{"metadata":{"number":9007199254740993}}`}, + {"clients update", []string{"c1"}, `{"unknown":true}`}, + {"clients update", []string{"c1"}, `[]`}, + {"clients update", []string{"c1"}, `{"name":"valid"} {}`}, + {"clients create", nil, `{"name":""}`}, + {"clients secrets create", []string{"c1"}, `{"name":" "}`}, + {"clients show", []string{".."}, ""}, + {"clients secrets delete", []string{"c1", "."}, ""}, + } + for _, tc := range cases { + t.Run(tc.path+tc.body, func(t *testing.T) { + p := auth.New(&http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + t.Fatal("invalid request reached network") + return nil, nil + })}) + req := pluginsdk.ExecuteRequest{CommandPath: strings.Fields("auth " + tc.path), Args: tc.args, Body: json.RawMessage(tc.body), Endpoint: "https://example.test/auth"} + if tc.body == "" { + req.Body = nil + } + if tc.path == "clients update" || tc.path == "clients secrets delete" { + req.Flags = map[string]string{"confirm": "true"} + } + res, err := p.Execute(t.Context(), req) + if err == nil || len(res.Data) != 0 { + t.Fatalf("data=%s err=%v", res.Data, err) + } + }) + } +} + +func TestPublicExecutionCancellation(t *testing.T) { + t.Parallel() + for _, path := range []string{"info", "clients create", "clients delete", "clients update"} { + t.Run(path, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + p := auth.New(&http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + t.Fatal("canceled request reached network") + return nil, nil + })}) + res, err := p.Execute(ctx, pluginsdk.ExecuteRequest{CommandPath: strings.Fields("auth " + path)}) + if !errors.Is(err, context.Canceled) || len(res.Data) != 0 { + t.Fatalf("data=%s err=%v", res.Data, err) + } + }) + } +} + +func TestPublicInFlightCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan struct{}) + p := auth.New(&http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + close(started) + <-r.Context().Done() + return nil, r.Context().Err() + })}) + done := make(chan error, 1) + go func() { + _, err := p.Execute(ctx, pluginsdk.ExecuteRequest{CommandPath: []string{"auth", "info"}, Endpoint: "https://example.test/auth"}) + done <- err + }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error=%v", err) + } +} + +func TestPublicUpdateCancellationPreventsWrite(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var calls atomic.Int32 + p := auth.New(&http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + if calls.Add(1) != 1 || r.Method != http.MethodGet { + t.Fatal("write after cancellation") + } + cancel() + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"data":{"id":"c1","name":"original"}}`))}, nil + })}) + res, err := p.Execute(ctx, pluginsdk.ExecuteRequest{ + CommandPath: []string{"auth", "clients", "update"}, Args: []string{"c1"}, Flags: map[string]string{"confirm": "true"}, + Body: json.RawMessage(`{"description":"new"}`), Endpoint: "https://example.test/auth", + }) + if !errors.Is(err, context.Canceled) || len(res.Data) != 0 || calls.Load() != 1 { + t.Fatalf("calls=%d data=%s err=%v", calls.Load(), res.Data, err) + } +} From 94f7c95801ca874eb3e61a60a03cae382edf3f1b Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:12:54 +0200 Subject: [PATCH 3/4] build(auth): package versioned fctl plugins with product releases --- .gitignore | 2 + .goreleaser.fctl-plugin.yml | 49 ++++++++++++++++++++++++ .goreleaser.yml | 34 +++++++++++++++++ Justfile | 18 ++++++++- misc/fctl-plugin/.golangci.yml | 70 ++++++++++++++++++++++++++++++++++ misc/fctl-plugin/README.md | 24 ++++++++++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 .goreleaser.fctl-plugin.yml create mode 100644 misc/fctl-plugin/.golangci.yml diff --git a/.gitignore b/.gitignore index 6a0715e..9e6a306 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ vendor .idea coverage.* auth + +/build/ diff --git a/.goreleaser.fctl-plugin.yml b/.goreleaser.fctl-plugin.yml new file mode 100644 index 0000000..83d1b87 --- /dev/null +++ b/.goreleaser.fctl-plugin.yml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 +project_name: fctl-plugin-auth +dist: build/fctl-plugin + +env: + - PLUGIN_REVISION={{ envOrDefault "PLUGIN_REVISION" "1" }} + - AUTH_SERVICE_VERSION={{ envOrDefault "AUTH_SERVICE_VERSION" .Version }} + +builds: + - id: fctl-plugin-auth + binary: fctl-plugin-auth + dir: misc/fctl-plugin + main: ./cmd/fctl-plugin-auth + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.serviceVersion={{ .Env.AUTH_SERVICE_VERSION }} + - -X main.revision={{ .Env.PLUGIN_REVISION }} + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + +archives: + - ids: + - fctl-plugin-auth + name_template: "{{ .ProjectName }}_v{{ .Env.AUTH_SERVICE_VERSION }}_r{{ .Env.PLUGIN_REVISION }}_{{ .Os }}-{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - src: misc/fctl-plugin/README.md + dst: README.md + +checksum: + name_template: checksums.txt + +release: + disable: true +changelog: + disable: true diff --git a/.goreleaser.yml b/.goreleaser.yml index 3a9e447..ba7dadc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -6,6 +6,9 @@ includes: - from_url: url: https://raw.githubusercontent.com/formancehq/earthly/refs/heads/main/.goreleaser.default-v2.yaml +env: + - PLUGIN_REVISION={{ envOrDefault "PLUGIN_REVISION" "1" }} + builds: - binary: auth id: auth @@ -22,6 +25,24 @@ builds: - amd64 - arm64 + - binary: fctl-plugin-auth + id: fctl-plugin-auth + dir: misc/fctl-plugin + main: ./cmd/fctl-plugin-auth + ldflags: + - -s -w + - -X main.serviceVersion={{ .Version }} + - -X main.revision={{ .Env.PLUGIN_REVISION }} + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + archives: - id: "{{.ProjectName}}" ids: @@ -29,6 +50,19 @@ archives: formats: [tar.gz] name_template: "{{.ProjectName}}_{{.Os}}-{{.Arch}}" + - id: fctl-plugin-auth + ids: + - fctl-plugin-auth + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "fctl-plugin-auth_v{{ .Version }}_r{{ .Env.PLUGIN_REVISION }}_{{ .Os }}-{{ .Arch }}" + files: + - src: misc/fctl-plugin/README.md + dst: README.md release: prerelease: auto diff --git a/Justfile b/Justfile index c0f864f..9aac5d0 100644 --- a/Justfile +++ b/Justfile @@ -8,14 +8,16 @@ pc: pre-commit lint: @golangci-lint run --fix --build-tags it --timeout 5m + @cd misc/fctl-plugin && golangci-lint run --config .golangci.yml --fix --timeout 5m tidy: @go mod tidy + @cd misc/fctl-plugin && go mod tidy generate: @go generate ./... -tests: +tests: test-fctl-plugin @go test -race -covermode=atomic \ -coverprofile coverage.txt \ -tags it \ @@ -32,3 +34,17 @@ release-ci: release: @goreleaser release --clean + +# Independent Auth CLI/library module; no Auth server dependencies. +test-fctl-plugin: + @cd misc/fctl-plugin && go test -race ./... + +build-fctl-plugin version="dev" revision="1": + @cd misc/fctl-plugin && CGO_ENABLED=0 go build -ldflags '-X main.serviceVersion={{version}} -X main.revision={{revision}}' -o ../../build/fctl-plugin-auth ./cmd/fctl-plugin-auth + +# Six platform executables and checksums, without publication. +package-fctl-plugin version="dev" revision="1": + @AUTH_SERVICE_VERSION={{version}} PLUGIN_REVISION={{revision}} goreleaser release --config .goreleaser.fctl-plugin.yml --snapshot --clean --skip=publish + +fctl-plugin-manifest: + @./build/fctl-plugin-auth --manifest > ./build/fctl-plugin-manifest.json diff --git a/misc/fctl-plugin/.golangci.yml b/misc/fctl-plugin/.golangci.yml new file mode 100644 index 0000000..d0236c6 --- /dev/null +++ b/misc/fctl-plugin/.golangci.yml @@ -0,0 +1,70 @@ +# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json +version: "2" + +formatters: + enable: + - gci + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/formancehq/) + - localmodule + custom-order: true + +run: + timeout: 5m + +linters: + default: none + enable: + - bodyclose + - contextcheck + - errcheck + - errorlint + - exhaustive + - gocognit + - gosec + - govet + - ineffassign + - modernize + - nilerr + - noctx + - nolintlint + - revive + - staticcheck + - unused + - usetesting + settings: + errcheck: + check-blank: true + check-type-assertions: true + disable-default-exclusions: true + exhaustive: + # A default branch must not hide a newly added enum value. + default-signifies-exhaustive: false + gocognit: + min-complexity: 15 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: increment-decrement + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: var-naming diff --git a/misc/fctl-plugin/README.md b/misc/fctl-plugin/README.md index c4ace34..537b29b 100644 --- a/misc/fctl-plugin/README.md +++ b/misc/fctl-plugin/README.md @@ -80,3 +80,27 @@ checks the host authentication broker, gateway prefix, creation form, exact JSON and rejected payloads. These are local fixtures, not live Auth validation. The fctl loader, host UI and distribution pipeline are maintained separately. Root-module `go test ./...` does not discover this nested module. + +## Product builds + +From the Auth repository root, `just test-fctl-plugin` tests this module with +race detection. The normal `just tests`, `just tidy` and `just lint` recipes +also include it. Build and inspect the exact service version with: + +```sh +nix develop --impure --command just build-fctl-plugin 2.5.0-beta.1 1 +nix develop --impure --command just fctl-plugin-manifest +nix develop --impure --command just package-fctl-plugin 2.5.0-beta.1 1 +``` + +The snapshot recipe builds Linux, macOS and Windows executables for amd64 and +arm64, with archives and checksums under `build/fctl-plugin`. It skips +publication. The product GoReleaser configuration attaches the same six builds +to Auth releases, using the release service version and `PLUGIN_REVISION`. + +fctl installs this executable with `plugins install --service auth --binary` +or resolves an exact-version OCI release with `plugins sync --service auth`. +Catalogue schema 1 entries use service `auth`, the manifest emitted by the +actual binary, the executable checksum and immutable OCI artifact digest. +Uploading public artifacts and advertising them in the official catalogue are +separate release operations; a snapshot does neither. From add6bd4c5dd7899f4b3103a77c01eda510d6d9b2 Mon Sep 17 00:00:00 2001 From: Maxence Maireaux Date: Fri, 9 Oct 2026 17:37:32 +0200 Subject: [PATCH 4/4] ci(auth): publish fctl plugins to GHCR on tagged releases --- .github/workflows/releases.yml | 16 +- Justfile | 47 +++ flake.nix | 5 + misc/fctl-plugin/README.md | 74 +++++ .../cmd/fctl-plugin-publish/main.go | 47 +++ .../cmd/fctl-plugin-publish/main_test.go | 95 ++++++ .../fctl-plugin/internal/catalogue/publish.go | 281 +++++++++++++++++ .../internal/catalogue/publish_it_test.go | 282 ++++++++++++++++++ .../internal/catalogue/publish_test.go | 87 ++++++ .../fctl-plugin/internal/catalogue/release.go | 142 +++++++++ .../internal/catalogue/release_test.go | 117 ++++++++ 11 files changed, 1191 insertions(+), 2 deletions(-) create mode 100644 misc/fctl-plugin/cmd/fctl-plugin-publish/main.go create mode 100644 misc/fctl-plugin/cmd/fctl-plugin-publish/main_test.go create mode 100644 misc/fctl-plugin/internal/catalogue/publish.go create mode 100644 misc/fctl-plugin/internal/catalogue/publish_it_test.go create mode 100644 misc/fctl-plugin/internal/catalogue/publish_test.go create mode 100644 misc/fctl-plugin/internal/catalogue/release.go create mode 100644 misc/fctl-plugin/internal/catalogue/release_test.go diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 53a384b..0994a44 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -8,6 +8,11 @@ permissions: jobs: GoReleaser: + permissions: + contents: write + packages: write + env: + PLUGIN_REVISION: "1" runs-on: namespace-profile-linux-amd64-4vcpu steps: - uses: namespacelabs/nscloud-checkout-action@v9 @@ -19,6 +24,9 @@ jobs: with: token: ${{ secrets.NUMARY_GITHUB_TOKEN }} + - name: Validate Auth plugin with race detection + run: nix develop --impure --command just test-fctl-plugin test-fctl-plugin-publisher + - run: nix develop --impure --command just release env: GITHUB_TOKEN: ${{ secrets.NUMARY_GITHUB_TOKEN }} @@ -26,8 +34,12 @@ jobs: FURY_TOKEN: ${{ secrets.FURY_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} + - name: Publish Auth fctl plugin and release catalogue + run: nix develop --impure --command just publish-fctl-plugin-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Add the OpenAPI file to the release assets - run: gh release upload "$TAG_NAME" ./openapi.yaml#openapi.yaml + run: nix develop --impure --command just upload-release-openapi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ github.ref_name }} diff --git a/Justfile b/Justfile index 9aac5d0..e69e458 100644 --- a/Justfile +++ b/Justfile @@ -48,3 +48,50 @@ package-fctl-plugin version="dev" revision="1": fctl-plugin-manifest: @./build/fctl-plugin-auth --manifest > ./build/fctl-plugin-manifest.json + +# Release publication uses the native executable from this exact GoReleaser run. +publish-fctl-plugin-release: + #!/usr/bin/env bash + set -euo pipefail + : "${GITHUB_REF_NAME:?release tag is required}" + : "${GITHUB_TOKEN:?GitHub Actions token is required}" + : "${GITHUB_ACTOR:?publishing actor is required}" + [[ "$GITHUB_REF_NAME" == v* ]] || { echo 'Expected an Auth v-prefixed release tag' >&2; exit 1; } + version="${GITHUB_REF_NAME#v}" + revision="${PLUGIN_REVISION:-1}" + native="$(jq -er '[.[] | select(.type == "Binary" and .extra.ID == "fctl-plugin-auth" and .goos == "linux" and .goarch == "amd64")] | if length == 1 then .[0].path else error("expected one native Auth plugin") end' dist/artifacts.json)" + : "${GITHUB_REPOSITORY:?release repository is required}" + release_root="$PWD" + auth_dir="$(mktemp -d)" + trap 'rm -rf "$auth_dir"; rm -f "$release_root/dist/fctl-plugin-catalogue.json.tmp" "$release_root/dist/fctl-plugin-manifest.json.tmp"' EXIT + export DOCKER_CONFIG="$auth_dir" + "$native" --manifest > dist/fctl-plugin-manifest.json.tmp + printf '%s' "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + cd misc/fctl-plugin + go run ./cmd/fctl-plugin-publish --manifest ../../dist/fctl-plugin-manifest.json.tmp --artifacts ../../dist/artifacts.json --source-root ../.. --service-version "$version" --revision "$revision" > ../../dist/fctl-plugin-catalogue.json.tmp + cd ../.. + mv dist/fctl-plugin-catalogue.json.tmp dist/fctl-plugin-catalogue.json + GH_TOKEN="$GITHUB_TOKEN" gh release upload "$GITHUB_REF_NAME" dist/fctl-plugin-catalogue.json --clobber --repo "$GITHUB_REPOSITORY" + +# Real ORAS layout fixture; no registry authentication or writes. +test-fctl-plugin-publisher: + @cd misc/fctl-plugin && go test -race -tags it ./internal/catalogue ./cmd/fctl-plugin-publish + +upload-release-openapi: + @gh release upload "$GITHUB_REF_NAME" ./openapi.yaml#openapi.yaml --repo "$GITHUB_REPOSITORY" + +# Read-only check after an administrator enables public GHCR downloads. +verify-fctl-plugin-anonymous catalogue="dist/fctl-plugin-catalogue.json": + #!/usr/bin/env bash + set -euo pipefail + catalogue_path="$(realpath '{{catalogue}}')" + fixture="$(mktemp -d)" + trap 'rm -rf "$fixture"' EXIT + printf '{"auths":{}}' > "$fixture/config.json" + jq -e '.schemaVersion == 1 and (.releases | length == 6)' "$catalogue_path" > /dev/null + while IFS=$'\t' read -r registry repository digest checksum; do + reference="${registry#https://}/$repository@$digest" + oras pull --registry-config "$fixture/config.json" --output "$fixture/artifact" "$reference" + printf '%s %s\n' "$checksum" "$fixture/artifact/fctl-plugin-auth" | sha256sum --check + rm -rf "$fixture/artifact" + done < <(jq -r '.releases[] | [.artifact.registry, .artifact.repository, .artifact.digest, .sha256] | @tsv' "$catalogue_path") diff --git a/flake.nix b/flake.nix index 9d7141a..f112f35 100644 --- a/flake.nix +++ b/flake.nix @@ -91,6 +91,11 @@ go_1_26 gotools just + bash + coreutils + jq + gh + oras ]; unstablePackages = with pkgs-unstable; [ golangci-lint diff --git a/misc/fctl-plugin/README.md b/misc/fctl-plugin/README.md index 537b29b..4c6a31b 100644 --- a/misc/fctl-plugin/README.md +++ b/misc/fctl-plugin/README.md @@ -104,3 +104,77 @@ Catalogue schema 1 entries use service `auth`, the manifest emitted by the actual binary, the executable checksum and immutable OCI artifact digest. Uploading public artifacts and advertising them in the official catalogue are separate release operations; a snapshot does neither. + +## Tagged release publication + +After this workflow change is merged, the **next** Auth tag matching `v*.*.*` +starts `.github/workflows/releases.yml`. No existing release is republished. +For releases cut from a maintenance branch, cherry-pick the release tooling, +plugin module, GoReleaser configuration and Nix toolchain changes onto that +branch before tagging. Merging to another branch alone does not update its +release workflow. + +Every work step runs through Nix and a root Just recipe. Before GoReleaser, +CI runs the plugin tests and real local OCI layout fixtures with race detection. +GoReleaser builds the server and the six plugin executables once. The publisher +consumes `dist/artifacts.json`, exports `--manifest` from that run's Linux/amd64 +binary, and checks every binary before the first upload: + +- Auth manifest name, service, root command and SDK protocol. +- Exact service version from the tag with the leading `v` removed. +- Separate positive `PLUGIN_REVISION` (currently `1` in the workflow). +- Auth executable entry point and embedded GOOS/GOARCH matching all six + Linux, macOS and Windows amd64/arm64 combinations. +- Contained regular executable files, bounded sizes, and actual SHA-256 checksums. + +Keep linker build metadata enabled: `-trimpath` would remove the linker flags +needed for this preflight. The publisher uses only the standard library and +public `pluginsdk`; it does not import fctl internals. + +ORAS from the pinned Nix flake authenticates to GHCR with the job's +`GITHUB_TOKEN`, scoped to `packages: write`, through `--password-stdin`. +Credentials stay in a temporary Docker config removed when the recipe exits. +The publisher pushes to `ghcr.io/formancehq/fctl-plugin-auth`. Each artifact has +one raw executable layer (`application/vnd.formance.fctl.plugin.executable.v1`), +artifact type `application/vnd.formance.fctl.plugin.v1`, and an empty JSON config +(`application/vnd.oci.empty.v1+json`), using OCI image specification 1.1. + +Only after all six pushes return valid immutable SHA-256 digests does the +publisher emit catalogue schemaVersion `1`. Each `releases` entry includes +`service`, `serviceVersion`, `revision`, `platform` (`os`, `arch`), `artifact` +(`registry`, `repository`, `digest`), `sha256`, and the native SDK `manifest`. +CI atomically replaces `dist/fctl-plugin-catalogue.json` and uploads it as an +Auth GitHub release asset. A preflight failure uploads nothing. An ORAS failure +emits no catalogue, though earlier successful platform uploads can remain. +Rerunning the tagged job reuses checksum-qualified tags and replaces the +catalogue asset only after a complete successful set. + +To test artifact serialization without contacting a registry: + +```sh +nix develop --impure --command just test-fctl-plugin test-fctl-plugin-publisher +``` + +The `fctl-plugin-publish` helper also accepts `--layout /absolute/fixture/path`, +`--manifest`, `--artifacts`, `--source-root`, `--service-version` and `--revision`. +For root GoReleaser builds, source-root is the repository root because artifact +paths include `dist/`; absolute contained artifact paths also work. Layout mode +still names GHCR in catalogue metadata, but creates no hosted download endpoint. + +### First-publication GHCR setup + +GitHub creates new GHCR packages privately by default. After the first successful +publication, an organization/package administrator must set +`formancehq/fctl-plugin-auth` to **Public** in GitHub package settings. For a +private Auth source repository, remove inherited repository permissions if +required and retain publishing access for the Auth Actions repository. If the +package already exists, grant this repository Actions access before release. +The workflow does not change package visibility or source repository visibility. +See [GitHub's Container registry documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). + +After that setup, use `just verify-fctl-plugin-anonymous` against the generated +catalogue (or a downloaded release asset) to fetch every artifact and verify +each executable checksum with an empty registry credential file. Run it before +advertising the entries in the official fctl catalogue. Release-asset publication +and public download availability are separate gates; the Auth workflow does not +change fctl's default catalogue or dependency pins. diff --git a/misc/fctl-plugin/cmd/fctl-plugin-publish/main.go b/misc/fctl-plugin/cmd/fctl-plugin-publish/main.go new file mode 100644 index 0000000..06166a5 --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-publish/main.go @@ -0,0 +1,47 @@ +// fctl-plugin-publish uploads GoReleaser binaries and emits catalogue schema 1. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + + "github.com/formancehq/auth/misc/fctl-plugin/internal/catalogue" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + log.New(os.Stderr, "", 0).Print(err) + os.Exit(1) + } +} + +func run(args []string, output, diagnostics io.Writer) error { + flags := flag.NewFlagSet("fctl-plugin-publish", flag.ContinueOnError) + flags.SetOutput(diagnostics) + var options catalogue.PublishOptions + flags.StringVar(&options.ServiceVersion, "service-version", "", "Exact Auth service version (release tag without v)") + flags.StringVar(&options.ManifestPath, "manifest", "", "SDK manifest exported by the native plugin") + flags.StringVar(&options.ArtifactsPath, "artifacts", "", "GoReleaser artifacts.json") + flags.StringVar(&options.SourceRoot, "source-root", "", "Root containing raw GoReleaser binaries") + flags.StringVar(&options.Registry, "registry", "https://ghcr.io", "OCI registry HTTPS origin") + flags.StringVar(&options.Repository, "repository", "formancehq/fctl-plugin-auth", "OCI repository") + flags.StringVar(&options.Layout, "layout", "", "Write a local OCI image layout instead of pushing to the registry") + flags.IntVar(&options.Revision, "revision", 1, "Positive plugin revision for the exact Auth version") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %v", flags.Args()) + } + + return catalogue.Publish(context.Background(), options, output, diagnostics) +} diff --git a/misc/fctl-plugin/cmd/fctl-plugin-publish/main_test.go b/misc/fctl-plugin/cmd/fctl-plugin-publish/main_test.go new file mode 100644 index 0000000..e9f2fdd --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-publish/main_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +func TestRunHelpDocumentsPublisherFlags(t *testing.T) { + t.Parallel() + var output, diagnostics bytes.Buffer + if err := run([]string{"--help"}, &output, &diagnostics); err != nil { + t.Fatal(err) + } + for _, flag := range []string{"service-version", "manifest", "artifacts", "source-root", "registry", "repository", "layout", "revision"} { + if !strings.Contains(diagnostics.String(), "-"+flag) { + t.Errorf("help omits --%s: %s", flag, &diagnostics) + } + } + if !strings.Contains(diagnostics.String(), "formancehq/fctl-plugin-auth") || output.Len() != 0 { + t.Fatalf("help must document Auth repository on diagnostics only: output=%s diagnostics=%s", &output, &diagnostics) + } +} + +func TestRunRejectsInvalidArgumentsWithoutCatalogue(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"--unknown"}, {"--revision", "invalid"}, {"unexpected"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var output, diagnostics bytes.Buffer + if err := run(args, &output, &diagnostics); err == nil { + t.Fatal("invalid arguments accepted") + } + if output.Len() != 0 { + t.Fatalf("invalid arguments advertised catalogue: %s", &output) + } + }) + } +} + +func TestRunRequiresServiceVersionAndPassesExactVersionToPublisher(t *testing.T) { + t.Parallel() + manifest, err := auth.NewVersion(nil, "3.0.0-beta.10").GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + manifestPath := filepath.Join(directory, "manifest.json") + if err := os.WriteFile(manifestPath, data, 0o600); err != nil { + t.Fatal(err) + } + artifactsPath := filepath.Join(directory, "artifacts.json") + if err := os.WriteFile(artifactsPath, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + for _, version := range []string{"", "3.0.1", "v3.0.0-beta.10", manifest.Version} { + t.Run("version="+version, func(t *testing.T) { + assertPublisherVersion(t, directory, manifestPath, artifactsPath, version, manifest.Version) + }) + } +} + +func assertPublisherVersion(t *testing.T, directory, manifestPath, artifactsPath, version, manifestVersion string) { + t.Helper() + layout := filepath.Join(t.TempDir(), "must-not-publish") + args := []string{"--manifest", manifestPath, "--artifacts", artifactsPath, "--source-root", directory, + "--registry", "https://ghcr.io", "--repository", "formancehq/fctl-plugin-auth", "--revision", "2", "--layout", layout} + if version != "" { + args = append(args, "--service-version", version) + } + var output, diagnostics bytes.Buffer + err := run(args, &output, &diagnostics) + want := "service version" + if version == manifestVersion { + // Matching version must reach artifact preflight, proving flag wiring. + want = "expected six" + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q error, got %v", want, err) + } + if output.Len() != 0 { + t.Fatalf("failed preflight advertised catalogue: %s", &output) + } + if _, err := os.Stat(layout); !os.IsNotExist(err) { + t.Fatalf("failed preflight reached publication: %v", err) + } +} diff --git a/misc/fctl-plugin/internal/catalogue/publish.go b/misc/fctl-plugin/internal/catalogue/publish.go new file mode 100644 index 0000000..ada64cb --- /dev/null +++ b/misc/fctl-plugin/internal/catalogue/publish.go @@ -0,0 +1,281 @@ +package catalogue + +import ( + "context" + "debug/buildinfo" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type PublishOptions struct { + ServiceVersion string + ManifestPath string + ArtifactsPath string + SourceRoot string + Registry string + Repository string + Revision int + Layout string +} + +// buildArtifact is the GoReleaser artifacts.json contract used by its Go builds. +type buildArtifact struct { + Path string `json:"path"` + OS string `json:"goos"` + Arch string `json:"goarch"` + Type string `json:"type"` + Extra struct { + ID string `json:"ID"` + } `json:"extra"` +} + +// Publish uploads raw GoReleaser executables and emits the catalogue only after +// every platform succeeds. Layout writes real OCI artifacts locally, without +// contacting a registry. Diagnostics never share the catalogue output stream. +func Publish(ctx context.Context, options PublishOptions, output, diagnostics io.Writer) (err error) { + if options.Layout != "" { + options.Layout, err = filepath.Abs(options.Layout) + if err != nil { + return err + } + } + stagePath, err := os.MkdirTemp("", "fctl-plugin-publish-") + if err != nil { + return err + } + defer func() { err = errors.Join(err, os.RemoveAll(stagePath)) }() + releases, err := prepare(options, stagePath) + if err != nil { + return err + } + origin, err := url.Parse(options.Registry) + if err != nil { + return err + } + tags, err := releaseTags(releases) + if err != nil { + return err + } + for index, release := range releases { + digest, err := push(ctx, options, origin, stagePath, release, tags[index], diagnostics) + if err != nil { + return err + } + releases[index].Artifact.Digest = digest + if err := validateArtifact(releases[index].Artifact); err != nil { + return err + } + } + return json.NewEncoder(output).Encode(Catalogue{SchemaVersion: 1, Releases: releases}) +} + +// prepare freezes and validates every binary before any registry operation. +func prepare(options PublishOptions, stagePath string) (releases []Release, err error) { + manifest, err := ReadManifest(options.ManifestPath) + if err != nil { + return nil, err + } + if options.ServiceVersion == "" || manifest.Version != options.ServiceVersion { + return nil, errors.New("manifest service version must match the exact release service version") + } + data, err := os.ReadFile(options.ArtifactsPath) // #nosec G304 -- Explicit release-tool input, not plugin metadata. + if err != nil { + return nil, err + } + artifacts, err := readArtifacts(data) + if err != nil { + return nil, err + } + + sourceRoot, err := os.OpenRoot(options.SourceRoot) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, sourceRoot.Close()) }() + sourcePath, err := filepath.Abs(options.SourceRoot) + if err != nil { + return nil, err + } + stageRoot, err := os.OpenRoot(stagePath) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, stageRoot.Close()) }() + return prepareArtifacts(options, manifest, artifacts, sourceRoot, sourcePath, stageRoot, stagePath) +} + +func readArtifacts(data []byte) ([]buildArtifact, error) { + var artifacts []buildArtifact + if err := json.Unmarshal(data, &artifacts); err != nil { + return nil, fmt.Errorf("read GoReleaser artifacts: %w", err) + } + return artifacts, nil +} + +func prepareArtifacts(options PublishOptions, manifest pluginsdk.Manifest, artifacts []buildArtifact, sourceRoot *os.Root, sourcePath string, stageRoot *os.Root, stagePath string) ([]Release, error) { + var releases []Release + seen := make(map[Platform]bool) + for _, artifact := range artifacts { + if artifact.Type != "Binary" || artifact.Extra.ID != "fctl-plugin-auth" { + continue + } + platform := Platform{OS: artifact.OS, Arch: artifact.Arch} + if seen[platform] { + return nil, fmt.Errorf("duplicate binary for %s/%s", platform.OS, platform.Arch) + } + seen[platform] = true + name, err := stageBinary(sourceRoot, sourcePath, stageRoot, artifact, manifest.Version, options.Revision) + if err != nil { + return nil, err + } + release, err := NewRelease(manifest, filepath.Join(stagePath, name), + Artifact{Registry: options.Registry, Repository: options.Repository, Digest: "sha256:" + strings.Repeat("0", 64)}, + platform, options.Revision) + if err != nil { + return nil, err + } + releases = append(releases, release) + } + if len(releases) != 6 { + return nil, fmt.Errorf("expected six GoReleaser plugin platforms, got %d", len(releases)) + } + return releases, nil +} + +func releaseTags(releases []Release) ([]string, error) { + tags := make([]string, len(releases)) + for index, release := range releases { + tag := fmt.Sprintf("%s-r%d-%s-%s-%s", strings.ReplaceAll(release.ServiceVersion, "+", "_"), release.Revision, + release.Platform.OS, release.Platform.Arch, release.SHA256[:16]) + if len(tag) > 128 || strings.ContainsAny(tag, ":@") { + return nil, errors.New("service version produces an invalid OCI tag") + } + tags[index] = tag + } + return tags, nil +} + +func push(ctx context.Context, options PublishOptions, origin *url.URL, stagePath string, release Release, tag string, diagnostics io.Writer) (string, error) { + destination := origin.Host + "/" + options.Repository + ":" + tag + if options.Layout != "" { + destination = options.Layout + ":" + tag + } + args := []string{"push", destination, "fctl-plugin-auth:application/vnd.formance.fctl.plugin.executable.v1", + "--artifact-type", "application/vnd.formance.fctl.plugin.v1", "--image-spec", "v1.1", + "--config", "empty.json:application/vnd.oci.empty.v1+json", "--format", "json", + "--annotation", "org.opencontainers.image.source=https://github.com/formancehq/auth"} + if options.Layout != "" { + args = append(args, "--oci-layout") + } else if origin.Scheme == "http" { + args = append(args, "--plain-http") + } + command := exec.CommandContext(ctx, "oras", args...) // #nosec G204 -- Fixed executable and validated identity; no shell. + command.Dir = filepath.Join(stagePath, release.Platform.OS+"_"+release.Platform.Arch) + command.Stderr = diagnostics + result, err := command.Output() + if err != nil { + return "", fmt.Errorf("publish %s/%s: %w", release.Platform.OS, release.Platform.Arch, err) + } + var pushed struct { + Digest string `json:"digest"` + } + if err := json.Unmarshal(result, &pushed); err != nil { + return "", fmt.Errorf("read ORAS push digest: %w", err) + } + return pushed.Digest, nil +} + +func stageBinary(sourceRoot *os.Root, sourcePath string, stageRoot *os.Root, artifact buildArtifact, version string, revision int) (name string, err error) { + if artifact.OS != "linux" && artifact.OS != "darwin" && artifact.OS != "windows" || artifact.Arch != "amd64" && artifact.Arch != "arm64" { + return "", errors.New("unsupported GoReleaser platform") + } + relative := artifact.Path + if filepath.IsAbs(relative) { + var err error + relative, err = filepath.Rel(sourcePath, relative) + if err != nil { + return "", err + } + } + if !filepath.IsLocal(relative) { + return "", errors.New("GoReleaser executable path must remain inside its build root") + } + source, err := sourceRoot.Open(relative) + if err != nil { + return "", fmt.Errorf("open contained GoReleaser executable: %w", err) + } + defer func() { err = errors.Join(err, source.Close()) }() + if err := validateBinary(source, artifact, version, revision); err != nil { + return "", err + } + + directory := artifact.OS + "_" + artifact.Arch + if err := stageRoot.Mkdir(directory, 0o700); err != nil { + return "", err + } + if err := stageRoot.WriteFile(filepath.Join(directory, "empty.json"), []byte("{}"), 0o600); err != nil { + return "", err + } + name = filepath.Join(directory, "fctl-plugin-auth") + target, err := stageRoot.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", err + } + _, copyErr := io.Copy(target, source) + if err := errors.Join(copyErr, target.Close()); err != nil { + return "", err + } + + return name, nil +} + +func hasLinkValue(flags, name, value string) bool { + words := strings.Fields(flags) + var actual string + for index, word := range words { + if word == "-X" && index+1 < len(words) { + if field, ok := strings.CutPrefix(words[index+1], name+"="); ok { + actual = field + } + } + } + + return actual == value +} + +func validateBinary(source *os.File, artifact buildArtifact, version string, revision int) error { + info, err := source.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > maxBinaryBytes { + return errors.New("GoReleaser executable must be a regular file containing 1 byte to 128 MiB") + } + build, err := buildinfo.Read(source) + if err != nil { + return fmt.Errorf("read Go executable build identity: %w", err) + } + settings := make(map[string]string) + for _, setting := range build.Settings { + settings[setting.Key] = setting.Value + } + if settings["GOOS"] != artifact.OS || settings["GOARCH"] != artifact.Arch || + build.Path != "github.com/formancehq/auth/misc/fctl-plugin/cmd/fctl-plugin-auth" { + return errors.New("GoReleaser binary platform or product entry point does not match catalogue inputs") + } + if !hasLinkValue(settings["-ldflags"], "main.serviceVersion", version) || + !hasLinkValue(settings["-ldflags"], "main.revision", strconv.Itoa(revision)) { + return errors.New("GoReleaser binary service version or plugin revision does not match catalogue inputs; build without -trimpath to retain linker metadata") + } + return nil +} diff --git a/misc/fctl-plugin/internal/catalogue/publish_it_test.go b/misc/fctl-plugin/internal/catalogue/publish_it_test.go new file mode 100644 index 0000000..9045e42 --- /dev/null +++ b/misc/fctl-plugin/internal/catalogue/publish_it_test.go @@ -0,0 +1,282 @@ +//go:build it + +package catalogue + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +func TestPublisherCreatesSixRealOCIArtifacts(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("oras"); err != nil { + t.Fatal("run publisher integration tests in the repository-pinned Nix environment: ", err) + } + buildDirectory := t.TempDir() + artifacts := buildPlatforms(t, buildDirectory) + manifest, err := auth.NewVersion(nil, "3.0.0-beta.10").GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(buildDirectory, "manifest.json") + writeJSON(t, manifestPath, manifest) + artifactsPath := filepath.Join(buildDirectory, "artifacts.json") + writeJSON(t, artifactsPath, artifacts) + layout := filepath.Join(t.TempDir(), "oci") + options := PublishOptions{ServiceVersion: manifest.Version, ManifestPath: manifestPath, ArtifactsPath: artifactsPath, SourceRoot: buildDirectory, + Registry: "http://127.0.0.1:5000", Repository: "formancehq/fctl-plugin-auth", Revision: 2, Layout: layout} + for _, failure := range []string{"missing service version", "release tag not stripped", "stale manifest", "wrong manifest product", "wrong manifest service", "wrong manifest command", "wrong platform", "wrong revision", "mixed service versions", "mixed plugin revisions", "wrong entry point", "missing platform", "duplicate platform"} { + t.Run(failure, func(t *testing.T) { + invalid, expectedError := invalidPublishOptions(t, failure, options, manifest, artifacts, buildDirectory) + assertPreflightRejected(t, invalid, expectedError) + }) + } + t.Run("ORAS invocation failure emits no catalogue", func(t *testing.T) { + assertORASFailure(t, options) + }) + var output bytes.Buffer + if err := Publish(t.Context(), options, &output, io.Discard); err != nil { + t.Fatal(err) + } + var catalogue Catalogue + if err := json.Unmarshal(output.Bytes(), &catalogue); err != nil || catalogue.SchemaVersion != 1 || len(catalogue.Releases) != 6 { + t.Fatalf("catalogue=%s, err=%v", output.Bytes(), err) + } + verifyLayout(t, layout, buildDirectory, options.ServiceVersion, catalogue) +} + +func writeJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func buildPlatforms(t *testing.T, buildDirectory string) []buildArtifact { + t.Helper() + var artifacts []buildArtifact + for _, goos := range []string{"linux", "darwin", "windows"} { + for _, arch := range []string{"amd64", "arm64"} { + path := filepath.Join(buildDirectory, goos+"_"+arch) + buildPlugin(t, path, goos, arch, "-X main.serviceVersion=3.0.0-beta.10 -X main.revision=2", "./cmd/fctl-plugin-auth") + artifact := buildArtifact{Path: path, OS: goos, Arch: arch, Type: "Binary"} + artifact.Extra.ID = "fctl-plugin-auth" + artifacts = append(artifacts, artifact) + } + } + return artifacts +} + +func invalidPublishOptions(t *testing.T, failure string, options PublishOptions, manifest pluginsdk.Manifest, artifacts []buildArtifact, buildDirectory string) (PublishOptions, string) { + t.Helper() + invalid := options + invalid.Layout = filepath.Join(t.TempDir(), "must-not-publish") + expectedError := "service version or plugin revision" + switch failure { + case "missing service version": + invalid.ServiceVersion = "" + expectedError = "service version" + case "release tag not stripped": + invalid.ServiceVersion = "v" + manifest.Version + expectedError = "service version" + case "stale manifest": + stale := manifest + stale.Version = "3.0.999" + invalid.ManifestPath = filepath.Join(t.TempDir(), "stale.json") + writeJSON(t, invalid.ManifestPath, stale) + expectedError = "service version" + case "wrong manifest product", "wrong manifest service", "wrong manifest command": + wrong := manifest + switch failure { + case "wrong manifest product": + wrong.Name = "ledger" + case "wrong manifest service": + wrong.Service = "ledger" + case "wrong manifest command": + wrong.Root.Use = "ledger" + } + invalid.ManifestPath = filepath.Join(t.TempDir(), "wrong-product.json") + writeJSON(t, invalid.ManifestPath, wrong) + expectedError = "manifest must describe" + case "wrong platform": + mixed := append([]buildArtifact{}, artifacts...) + // The last binary claims windows/arm64 but contains linux/amd64. + mixed[len(mixed)-1].Path = artifacts[0].Path + invalid.ArtifactsPath = filepath.Join(t.TempDir(), "wrong-platform.json") + writeJSON(t, invalid.ArtifactsPath, mixed) + expectedError = "platform or product entry point" + case "missing platform": + invalid.ArtifactsPath = filepath.Join(t.TempDir(), "missing-platform.json") + writeJSON(t, invalid.ArtifactsPath, artifacts[:len(artifacts)-1]) + expectedError = "expected six" + case "duplicate platform": + mixed := append([]buildArtifact{}, artifacts...) + mixed = append(mixed, artifacts[0]) + invalid.ArtifactsPath = filepath.Join(t.TempDir(), "duplicate-platform.json") + writeJSON(t, invalid.ArtifactsPath, mixed) + expectedError = "duplicate binary" + case "wrong revision": + invalid.Revision = 99 + case "mixed service versions", "mixed plugin revisions": + path := filepath.Join(buildDirectory, strings.ReplaceAll(failure, " ", "-")+"-windows-arm64") + linkFlags := "-X main.serviceVersion=3.0.1 -X main.revision=2" + if failure == "mixed plugin revisions" { + linkFlags = "-X main.serviceVersion=3.0.0-beta.10 -X main.revision=99" + } + buildPlugin(t, path, "windows", "arm64", linkFlags, "./cmd/fctl-plugin-auth") + mixed := append([]buildArtifact{}, artifacts...) + mixed[len(mixed)-1].Path = path + invalid.ArtifactsPath = filepath.Join(t.TempDir(), "mixed.json") + writeJSON(t, invalid.ArtifactsPath, mixed) + case "wrong entry point": + path := filepath.Join(buildDirectory, "wrong-entry-point") + buildPlugin(t, path, "windows", "arm64", "-X main.serviceVersion=3.0.0-beta.10 -X main.revision=2", "./cmd/fctl-plugin-publish") + mixed := append([]buildArtifact{}, artifacts...) + mixed[len(mixed)-1].Path = path + invalid.ArtifactsPath = filepath.Join(t.TempDir(), "wrong-entry-point.json") + writeJSON(t, invalid.ArtifactsPath, mixed) + expectedError = "product entry point" + } + return invalid, expectedError +} + +func assertORASFailure(t *testing.T, options PublishOptions) { + t.Helper() + invalid := options + // A file cannot contain an OCI layout. Real ORAS must fail locally. + invalid.Layout = filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(invalid.Layout, []byte("block layout creation"), 0o600); err != nil { + t.Fatal(err) + } + var rejected, diagnostics bytes.Buffer + err := Publish(t.Context(), invalid, &rejected, &diagnostics) + if err == nil || !strings.Contains(err.Error(), "publish linux/amd64") { + t.Fatalf("expected ORAS invocation failure, got %v (diagnostics: %s)", err, &diagnostics) + } + if rejected.Len() != 0 { + t.Fatalf("failed ORAS advertised a catalogue: %s", &rejected) + } +} + +func verifyLayout(t *testing.T, layout, buildDirectory, version string, catalogue Catalogue) { + t.Helper() + root, err := os.OpenRoot(layout) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + source := openTestRoot(t, buildDirectory) + for _, release := range catalogue.Releases { + verifyRelease(t, root, source, version, release) + } +} + +func verifyRelease(t *testing.T, root, source *os.Root, version string, release Release) { + t.Helper() + digest := strings.TrimPrefix(release.Artifact.Digest, "sha256:") + data, err := root.ReadFile(filepath.Join("blobs", "sha256", digest)) + if err != nil { + t.Fatal(err) + } + checksum := sha256.Sum256(data) + if hex.EncodeToString(checksum[:]) != digest || release.ServiceVersion != "3.0.0-beta.10" || release.Revision != 2 { + t.Fatalf("invalid immutable release identity: %#v", release) + } + verifyOCIManifest(t, data, release.SHA256) + binary, err := root.ReadFile(filepath.Join("blobs", "sha256", release.SHA256)) + if err != nil { + t.Fatal(err) + } + original, err := source.ReadFile(release.Platform.OS + "_" + release.Platform.Arch) + if err != nil { + t.Fatal(err) + } + checksum = sha256.Sum256(binary) + if !bytes.Equal(binary, original) || hex.EncodeToString(checksum[:]) != release.SHA256 || + release.Service != "auth" || release.Manifest.Name != "auth" || release.Manifest.Version != version { + t.Fatalf("OCI executable or Auth manifest differs from release input: %#v", release) + } +} + +func verifyOCIManifest(t *testing.T, data []byte, checksum string) { + t.Helper() + var image struct { + MediaType string `json:"mediaType"` + ArtifactType string `json:"artifactType"` + Config struct { + MediaType string `json:"mediaType"` + } `json:"config"` + Layers []struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + } `json:"layers"` + } + if err := json.Unmarshal(data, &image); err != nil { + t.Fatal(err) + } + if image.MediaType != "application/vnd.oci.image.manifest.v1+json" || image.ArtifactType != "application/vnd.formance.fctl.plugin.v1" || + image.Config.MediaType != "application/vnd.oci.empty.v1+json" || len(image.Layers) != 1 || + image.Layers[0].MediaType != "application/vnd.formance.fctl.plugin.executable.v1" || image.Layers[0].Digest != "sha256:"+checksum { + t.Fatalf("unexpected OCI manifest: %s", data) + } + +} + +func buildPlugin(t *testing.T, path, goos, arch, linkFlags, entryPoint string) { + t.Helper() + //nolint:gosec // Arguments are controlled test fixtures; go runs directly without a shell. + command := exec.CommandContext(t.Context(), "go", "build", "-o", path, "-ldflags", linkFlags, entryPoint) + command.Dir = "../.." + command.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+arch, "CGO_ENABLED=0") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build %s/%s: %s, %v", goos, arch, output, err) + } +} + +func openTestRoot(t *testing.T, path string) *os.Root { + t.Helper() + root, err := os.OpenRoot(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + return root +} + +func assertPreflightRejected(t *testing.T, invalid PublishOptions, expectedError string) { + t.Helper() + var rejected bytes.Buffer + if err := Publish(t.Context(), invalid, &rejected, io.Discard); err == nil || !strings.Contains(err.Error(), expectedError) { + t.Fatalf("wrong release identity accepted or wrong rejection: %v", err) + } + if rejected.Len() != 0 { + t.Fatalf("failed preflight advertised a catalogue: %s", rejected.Bytes()) + } + if _, err := os.Stat(invalid.Layout); !os.IsNotExist(err) { + t.Fatalf("failed preflight reached ORAS publication: %v", err) + } +} diff --git a/misc/fctl-plugin/internal/catalogue/publish_test.go b/misc/fctl-plugin/internal/catalogue/publish_test.go new file mode 100644 index 0000000..0b86f34 --- /dev/null +++ b/misc/fctl-plugin/internal/catalogue/publish_test.go @@ -0,0 +1,87 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +func TestArtifactPathsStayInsideGoReleaserBuildRoot(t *testing.T) { + t.Parallel() + parent := t.TempDir() + buildPath := filepath.Join(parent, "build") + if err := os.Mkdir(buildPath, 0o700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(parent, "outside") + if err := os.WriteFile(outside, []byte("must not publish this file"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(buildPath, "escape")); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(buildPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + stagePath := t.TempDir() + stage, err := os.OpenRoot(stagePath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := stage.Close(); err != nil { + t.Error(err) + } + }) + for _, path := range []string{outside, "../outside", "escape"} { + artifact := buildArtifact{Path: path, OS: "linux", Arch: "amd64"} + _, err := stageBinary(root, buildPath, stage, artifact, "3.0.0-beta.10", 2) + if err == nil || strings.Contains(err.Error(), "build identity") { + t.Fatalf("path %q reached binary inspection outside root: %v", path, err) + } + } + entries, err := os.ReadDir(stagePath) + if err != nil || len(entries) != 0 { + t.Fatalf("rejected artifact produced staging files: %v, %v", entries, err) + } +} + +func TestPublishRequiresExactServiceVersionBeforeReadingArtifacts(t *testing.T) { + t.Parallel() + manifest, err := auth.NewVersion(nil, "3.0.0-beta.10").GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "manifest.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + for _, version := range []string{"", "3.0.1", "v3.0.0-beta.10"} { + t.Run("version="+version, func(t *testing.T) { + var output bytes.Buffer + options := PublishOptions{ManifestPath: path, ServiceVersion: version, ArtifactsPath: "does-not-exist"} + if err := Publish(t.Context(), options, &output, io.Discard); err == nil || !strings.Contains(err.Error(), "service version") { + t.Fatalf("expected exact service version rejection before reading artifacts, got %v", err) + } + if output.Len() != 0 { + t.Fatalf("rejected release advertised catalogue: %s", &output) + } + }) + } +} diff --git a/misc/fctl-plugin/internal/catalogue/release.go b/misc/fctl-plugin/internal/catalogue/release.go new file mode 100644 index 0000000..f4c65c0 --- /dev/null +++ b/misc/fctl-plugin/internal/catalogue/release.go @@ -0,0 +1,142 @@ +// Package catalogue prepares product release entries for the fctl catalogue. +// Its wire fields follow catalogue schemaVersion 1 without importing the core. +package catalogue + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "regexp" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +type Artifact struct { + Registry string `json:"registry"` + Repository string `json:"repository"` + Digest string `json:"digest"` +} + +type Release struct { + Service string `json:"service"` + ServiceVersion string `json:"serviceVersion"` + Revision int `json:"revision"` + Platform Platform `json:"platform"` + Artifact Artifact `json:"artifact"` + SHA256 string `json:"sha256"` + Manifest pluginsdk.Manifest `json:"manifest"` +} + +type Catalogue struct { + SchemaVersion int `json:"schemaVersion"` + Releases []Release `json:"releases"` +} + +const ( + maxManifestBytes = 1 << 20 + maxBinaryBytes = 128 << 20 +) + +var repositoryPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$`) + +// ReadManifest reads the SDK metadata exported by the native executable. +func ReadManifest(path string) (manifest pluginsdk.Manifest, err error) { + file, err := os.Open(path) // #nosec G304 -- Explicit release-tool input, never a path from plugin metadata. + if err != nil { + return pluginsdk.Manifest{}, err + } + defer func() { err = errors.Join(err, file.Close()) }() + data, err := io.ReadAll(io.LimitReader(file, maxManifestBytes+1)) + if err != nil { + return pluginsdk.Manifest{}, err + } + if len(data) > maxManifestBytes { + return pluginsdk.Manifest{}, errors.New("plugin manifest exceeds 1 MiB") + } + if err := json.Unmarshal(data, &manifest); err != nil { + return pluginsdk.Manifest{}, fmt.Errorf("read SDK manifest: %w", err) + } + + return manifest, nil +} + +// NewRelease binds the actual raw binary checksum to an immutable OCI digest. +// Binary is an explicit operator-selected file, not a path from a manifest. +func NewRelease(manifest pluginsdk.Manifest, binary string, artifact Artifact, platform Platform, revision int) (Release, error) { + if manifest.Name != "auth" || manifest.Service != "auth" || pluginsdk.CommandName(manifest.Root) != "auth" || + manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return Release{}, errors.New("manifest must describe the Auth plugin and current SDK protocol") + } + if manifest.Version == "" || len(manifest.Version) > 128 || strings.TrimSpace(manifest.Version) != manifest.Version || + strings.ContainsAny(manifest.Version, "/\\\x00\r\n\t ") || revision < 1 { + return Release{}, errors.New("exact service version and positive plugin revision are required") + } + if platform.OS != "linux" && platform.OS != "darwin" && platform.OS != "windows" || + platform.Arch != "amd64" && platform.Arch != "arm64" { + return Release{}, errors.New("unsupported plugin platform") + } + if err := validateArtifact(artifact); err != nil { + return Release{}, err + } + checksum, err := hashBinary(binary) + if err != nil { + return Release{}, err + } + + return Release{Service: manifest.Service, ServiceVersion: manifest.Version, Revision: revision, + Platform: platform, Artifact: artifact, SHA256: checksum, Manifest: manifest}, nil +} + +func validateArtifact(artifact Artifact) error { + origin, err := url.Parse(artifact.Registry) + if err != nil || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { + return errors.New("registry must be an HTTPS origin without credentials") + } + ip := net.ParseIP(origin.Hostname()) + loopback := origin.Hostname() == "localhost" || ip != nil && ip.IsLoopback() + if origin.Scheme != "https" && (origin.Scheme != "http" || !loopback) { + return errors.New("registry requires HTTPS, except loopback development registries") + } + if len(artifact.Repository) > 255 || !repositoryPattern.MatchString(artifact.Repository) { + return errors.New("invalid OCI repository") + } + checksum, ok := strings.CutPrefix(artifact.Digest, "sha256:") + if !ok || len(checksum) != 64 || strings.ToLower(checksum) != checksum { + return errors.New("artifact requires an immutable sha256 digest") + } + if _, err := hex.DecodeString(checksum); err != nil { + return errors.New("artifact requires an immutable sha256 digest") + } + + return nil +} + +func hashBinary(path string) (result string, err error) { + file, err := os.Open(path) // #nosec G304 -- Explicit release-tool input, never a path from plugin metadata. + if err != nil { + return "", err + } + defer func() { err = errors.Join(err, file.Close()) }() + checksum := sha256.New() + size, err := io.Copy(checksum, io.LimitReader(file, maxBinaryBytes+1)) + if err != nil { + return "", err + } + if size == 0 || size > maxBinaryBytes { + return "", errors.New("raw plugin binary must contain 1 byte to 128 MiB") + } + + return hex.EncodeToString(checksum.Sum(nil)), nil +} diff --git a/misc/fctl-plugin/internal/catalogue/release_test.go b/misc/fctl-plugin/internal/catalogue/release_test.go new file mode 100644 index 0000000..764ec8a --- /dev/null +++ b/misc/fctl-plugin/internal/catalogue/release_test.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + auth "github.com/formancehq/auth/misc/fctl-plugin" +) + +func TestReleaseUsesSDKManifestAndActualRawBinaryChecksum(t *testing.T) { + t.Parallel() + manifest, err := auth.NewVersion(nil, "3.0.0-beta.5").GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + binary := filepath.Join(directory, "binary") + const data = "synthetic native executable bytes" + if err := os.WriteFile(binary, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + checksum := sha256.Sum256([]byte(data)) + artifact := Artifact{Registry: "https://ghcr.io", Repository: "formancehq/fctl-plugin-auth", Digest: "sha256:" + strings.Repeat("a", 64)} + release, err := NewRelease(manifest, binary, artifact, Platform{OS: "windows", Arch: "arm64"}, 2) + if err != nil || release.ServiceVersion != "3.0.0-beta.5" || release.Revision != 2 || release.SHA256 != hex.EncodeToString(checksum[:]) { + t.Fatalf("release=%#v, err=%v", release, err) + } + encoded, err := json.Marshal(Catalogue{SchemaVersion: 1, Releases: []Release{release}}) + if err != nil { + t.Fatal(err) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(encoded, &wire); err != nil || string(wire["schemaVersion"]) != "1" || wire["releases"] == nil { + t.Fatalf("catalogue wire=%s, err=%v", encoded, err) + } + manifestPath := filepath.Join(directory, "manifest.json") + encoded, err = json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, encoded, 0o600); err != nil { + t.Fatal(err) + } + read, err := ReadManifest(manifestPath) + if err != nil || read.Version != manifest.Version || len(read.Root.Subcommands) != len(manifest.Root.Subcommands) { + t.Fatalf("read manifest=%#v, err=%v", read, err) + } +} + +func TestNewReleaseRejectsInvalidIdentityBeforeOpeningBinary(t *testing.T) { + t.Parallel() + manifest, err := auth.NewVersion(nil, "3.0.0-beta.5").GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + artifact := Artifact{Registry: "https://ghcr.io", Repository: "formancehq/fctl-plugin-auth", Digest: "sha256:" + strings.Repeat("a", 64)} + for _, test := range []struct { + name string + mutate func(*pluginsdk.Manifest, *Platform, *int) + want string + }{ + {"product", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.Name = "ledger" }, "manifest must describe"}, + {"service", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.Service = "ledger" }, "manifest must describe"}, + {"command", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.Root.Use = "ledger" }, "manifest must describe"}, + {"protocol", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.ProtocolVersion++ }, "manifest must describe"}, + {"empty version", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.Version = "" }, "exact service version"}, + {"unsafe version", func(m *pluginsdk.Manifest, _ *Platform, _ *int) { m.Version = "../release" }, "exact service version"}, + {"zero revision", func(_ *pluginsdk.Manifest, _ *Platform, r *int) { *r = 0 }, "positive plugin revision"}, + {"negative revision", func(_ *pluginsdk.Manifest, _ *Platform, r *int) { *r = -1 }, "positive plugin revision"}, + {"unsupported OS", func(_ *pluginsdk.Manifest, p *Platform, _ *int) { p.OS = "freebsd" }, "unsupported plugin platform"}, + {"unsupported arch", func(_ *pluginsdk.Manifest, p *Platform, _ *int) { p.Arch = "386" }, "unsupported plugin platform"}, + } { + t.Run(test.name, func(t *testing.T) { + invalid := manifest + platform, revision := Platform{OS: "linux", Arch: "amd64"}, 2 + test.mutate(&invalid, &platform, &revision) + _, err := NewRelease(invalid, filepath.Join(t.TempDir(), "missing-binary"), artifact, platform, revision) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected identity rejection %q before binary access, got %v", test.want, err) + } + }) + } +} + +func TestImmutableArtifactValidation(t *testing.T) { + t.Parallel() + valid := Artifact{Registry: "https://ghcr.io", Repository: "formancehq/fctl-plugin-auth", Digest: "sha256:" + strings.Repeat("a", 64)} + for _, registry := range []string{"https://ghcr.io", "http://localhost:5000", "http://127.0.0.1:5000", "http://[::1]:5000"} { + artifact := valid + artifact.Registry = registry + if err := validateArtifact(artifact); err != nil { + t.Errorf("valid registry %s: %v", registry, err) + } + } + for _, mutate := range []func(*Artifact){ + func(a *Artifact) { a.Digest = "latest" }, + func(a *Artifact) { a.Digest = "sha256:" + strings.Repeat("A", 64) }, + func(a *Artifact) { a.Digest = "sha256:" + strings.Repeat("x", 64) }, + func(a *Artifact) { a.Registry = "http://registry.example" }, + func(a *Artifact) { a.Registry = "https://token@ghcr.io" }, + func(a *Artifact) { a.Registry = "https://ghcr.io/api" }, + func(a *Artifact) { a.Repository = "../secret" }, + } { + artifact := valid + mutate(&artifact) + if err := validateArtifact(artifact); err == nil { + t.Fatalf("accepted invalid artifact: %#v", artifact) + } + } +}