From cb1f24e32fb220e9fe29bce9e11470d5f3ea43c0 Mon Sep 17 00:00:00 2001 From: Timur Akhmadiev <42980811+Sugar-pack@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:17:47 +0400 Subject: [PATCH] feat: update system trust to support scope (#5639) --- pkg/config/systemtrust_test.go | 46 ++++++++++++-- pkg/systemtrust/systemtrust.go | 93 ++++++++++++++++++++--------- pkg/systemtrust/systemtrust_test.go | 80 ++++++++++++++++++++----- 3 files changed, 173 insertions(+), 46 deletions(-) diff --git a/pkg/config/systemtrust_test.go b/pkg/config/systemtrust_test.go index cd74b560f..fc83580f5 100644 --- a/pkg/config/systemtrust_test.go +++ b/pkg/config/systemtrust_test.go @@ -4,14 +4,16 @@ package config import ( + "bytes" + "encoding/json" "fmt" + "io" "net/http" "testing" piperhttp "github.com/SAP/jenkins-library/pkg/http" "github.com/SAP/jenkins-library/pkg/systemtrust" "github.com/jarcoal/httpmock" - "github.com/stretchr/testify/assert" ) @@ -19,25 +21,49 @@ const secretName = "sonar" const secretNameInSystemTrust = "sonarSystemtrustSecretName" const testServerURL = "https://www.project-piper.io" const testTokenEndPoint = "tokens" -const testTokenQueryParamName = "systems" +const testTokenQueryParamName = "systems" // no longer used by the new implementation, but kept in config const mockSonarToken = "mockSonarToken" -var testFullURL = fmt.Sprintf("%s/%s?%s=", testServerURL, testTokenEndPoint, testTokenQueryParamName) +var testFullURL = fmt.Sprintf("%s/%s", testServerURL, testTokenEndPoint) var mockSingleTokenResponse = fmt.Sprintf("{\"sonar\": \"%s\"}", mockSonarToken) func TestSystemTrustConfig(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() - httpmock.RegisterResponder(http.MethodGet, testFullURL+"sonar", httpmock.NewStringResponder(200, mockSingleTokenResponse)) + + httpmock.RegisterResponder(http.MethodPost, testFullURL, + func(req *http.Request) (*http.Response, error) { + // verify request body matches new POST contract + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return httpmock.NewStringResponse(http.StatusBadRequest, "failed to read body"), nil + } + + var got []map[string]string + if err := json.Unmarshal(bodyBytes, &got); err != nil { + return httpmock.NewStringResponse(http.StatusBadRequest, "invalid json body"), nil + } + + // Expect: [{"system":"sonar","scope":"pipeline"}] + if len(got) != 1 || got[0]["system"] != "sonar" || got[0]["scope"] != "pipeline" { + return httpmock.NewStringResponse(http.StatusBadRequest, "unexpected request body"), nil + } + + resp := httpmock.NewStringResponse(http.StatusOK, mockSingleTokenResponse) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }, + ) stepParams := []StepParameters{createStepParam(secretName, RefTypeSystemTrustSecret, secretNameInSystemTrust, secretName)} - var systemTrustConfiguration = systemtrust.Configuration{ + systemTrustConfiguration := systemtrust.Configuration{ Token: "testToken", ServerURL: testServerURL, TokenEndPoint: testTokenEndPoint, TokenQueryParamName: testTokenQueryParamName, } + client := &piperhttp.Client{} client.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true}) @@ -73,3 +99,13 @@ func createStepParam(name, refType, systemTrustSecretNameProperty, defaultSecret }, } } + +// Optional helper if you prefer exact JSON matching instead of map-based checks above. +func mustCompactJSON(t *testing.T, s string) string { + t.Helper() + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(s)); err != nil { + t.Fatalf("failed to compact json: %v", err) + } + return buf.String() +} diff --git a/pkg/systemtrust/systemtrust.go b/pkg/systemtrust/systemtrust.go index 49e58bcb5..1af87ca27 100644 --- a/pkg/systemtrust/systemtrust.go +++ b/pkg/systemtrust/systemtrust.go @@ -1,6 +1,7 @@ package systemtrust import ( + "bytes" "encoding/json" "fmt" "io" @@ -21,10 +22,6 @@ type Secret struct { System string } -type Response struct { - Secrets []Secret -} - type Configuration struct { ServerURL string TokenEndPoint string @@ -32,29 +29,61 @@ type Configuration struct { Token string } -// GetToken requests a single token +type tokenRequestArray = []tokenRequest + +type tokenRequest struct { + System string `json:"system"` + Scope string `json:"scope"` +} + +const defaultScope = "pipeline" + +// GetToken requests a single token. +// By default, refName is used as the system and the default scope is applied. +// If refName contains "", the value before "" is used as the system +// and the value after "" is propagated as the scope. func GetToken(refName string, client *piperhttp.Client, systemTrustConfiguration Configuration) (string, error) { - secrets, err := getSecrets([]string{refName}, client, systemTrustConfiguration) + body := refNameToTokenBody(refName) + secrets, err := getSecrets(client, systemTrustConfiguration, body) if err != nil { return "", fmt.Errorf("couldn't get token from System Trust: %w", err) } for _, s := range secrets { - if s.System == refName { + if s.System == body.System { return s.Token, nil } } return "", errors.New("could not find token in System Trust response") } -// getSecrets transforms the System Trust JSON response into System Trust secrets, and can be used to request multiple tokens -func getSecrets(refNames []string, client *piperhttp.Client, systemTrustConfiguration Configuration) ([]Secret, error) { - var secrets []Secret - query := url.Values{ - systemTrustConfiguration.TokenQueryParamName: { - strings.Join(refNames, ","), - }, +func refNameToTokenBody(refName string) tokenRequest { + const marker = "" + + system := refName + scope := defaultScope + + if strings.Contains(refName, marker) { + parts := strings.SplitN(refName, marker, 2) + + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + system = parts[0] + scope = parts[1] + } else { + log.Entry().Warnf("invalid scope format in refName '%s', using defaults", refName) + } } - response, err := getResponse(systemTrustConfiguration.ServerURL, systemTrustConfiguration.TokenEndPoint, query, client) + + return tokenRequest{ + System: system, + Scope: scope, + } +} + +// getSecrets using the system trust session token and convert to respectful system token based on request body +func getSecrets(client *piperhttp.Client, systemTrustConfiguration Configuration, requests ...tokenRequest) ([]Secret, error) { + var secrets []Secret + + response, err := getResponse(systemTrustConfiguration.ServerURL, systemTrustConfiguration.TokenEndPoint, client, requests) if err != nil { return secrets, fmt.Errorf("getting secrets from System Trust failed: %w", err) } @@ -68,18 +97,24 @@ func getSecrets(refNames []string, client *piperhttp.Client, systemTrustConfigur } // getResponse returns a map of the JSON response that the System Trust puts out -func getResponse(serverURL, endpoint string, query url.Values, client *piperhttp.Client) (map[string]string, error) { +func getResponse(serverURL, endpoint string, client *piperhttp.Client, body tokenRequestArray) (map[string]string, error) { var secrets map[string]string - rawURL, err := parseURL(serverURL, endpoint, query) + rawURL, err := parseURL(serverURL, endpoint) if err != nil { return secrets, fmt.Errorf("parsing System Trust url failed: %w", err) } + header := make(http.Header) header.Add("Accept", "application/json") - log.Entry().Debugf(" with URL %s", rawURL) - response, err := client.SendRequest(http.MethodGet, rawURL, nil, header, nil) + bodyReader, err := trustTokenRequestToReader(body) + if err != nil { + return secrets, fmt.Errorf("failed to marshal token request body: %w", err) + } + + log.Entry().Debugf(" with body %s", body) + response, err := client.SendRequest(http.MethodPost, rawURL, bodyReader, header, nil) if err != nil { if response != nil { // the body contains full error message which we want to log @@ -93,6 +128,8 @@ func getResponse(serverURL, endpoint string, query url.Values, client *piperhttp } defer response.Body.Close() + log.Entry().Debugf(" with response code %d", response.StatusCode) + err = json.NewDecoder(response.Body).Decode(&secrets) if err != nil { return secrets, fmt.Errorf("getting response from System Trust failed: %w", err) @@ -101,8 +138,16 @@ func getResponse(serverURL, endpoint string, query url.Values, client *piperhttp return secrets, nil } -// parseURL creates the full URL for a System Trust GET request -func parseURL(serverURL, endpoint string, query url.Values) (string, error) { +func trustTokenRequestToReader(body tokenRequestArray) (io.Reader, error) { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal token request body: %w", err) + } + return bytes.NewReader(b), nil +} + +// parseURL creates the full URL for a System Trust POST request +func parseURL(serverURL, endpoint string) (string, error) { rawFullEndpoint, err := url.JoinPath(serverURL, endpoint) if err != nil { return "", errors.New("error parsing System Trust URL") @@ -111,12 +156,6 @@ func parseURL(serverURL, endpoint string, query url.Values) (string, error) { if err != nil { return "", errors.New("error parsing System Trust URL") } - // commas and spaces shouldn't be escaped since the System Trust won't accept it - unescapedRawQuery, err := url.QueryUnescape(query.Encode()) - if err != nil { - return "", errors.New("error parsing System Trust URL") - } - fullURL.RawQuery = unescapedRawQuery return fullURL.String(), nil } diff --git a/pkg/systemtrust/systemtrust_test.go b/pkg/systemtrust/systemtrust_test.go index 3eb36e3be..cdef48ba4 100644 --- a/pkg/systemtrust/systemtrust_test.go +++ b/pkg/systemtrust/systemtrust_test.go @@ -4,7 +4,9 @@ package systemtrust import ( + "encoding/json" "fmt" + "io" "net/http" "testing" @@ -16,27 +18,45 @@ import ( const testServerURL = "https://www.project-piper.io" const testTokenEndPoint = "tokens" -const testTokenQueryParamName = "systems" const mockSonarToken = "mockSonarToken" const mockblackduckToken = "mockblackduckToken" const errorMsg403 = "unauthorized to request token" -var testFullURL = fmt.Sprintf("%s/%s?%s=", testServerURL, testTokenEndPoint, testTokenQueryParamName) +var testFullURL = fmt.Sprintf("%s/%s", testServerURL, testTokenEndPoint) + var mockSingleTokenResponse = fmt.Sprintf("{\"sonar\": \"%s\"}", mockSonarToken) var mockTwoTokensResponse = fmt.Sprintf("{\"sonar\": \"%s\", \"blackduck\": \"%s\"}", mockSonarToken, mockblackduckToken) + var systemTrustConfiguration = Configuration{ Token: "testToken", ServerURL: testServerURL, TokenEndPoint: testTokenEndPoint, - TokenQueryParamName: testTokenQueryParamName, + TokenQueryParamName: "systems", // no longer used by implementation, but kept for compatibility } func TestSystemTrust(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() - t.Run("Get Sonar token - happy path", func(t *testing.T) { - httpmock.RegisterResponder(http.MethodGet, testFullURL+"sonar", httpmock.NewStringResponder(200, mockSingleTokenResponse)) + t.Run("Get Sonar token - happy path (POST + JSON body)", func(t *testing.T) { + httpmock.RegisterResponder(http.MethodPost, testFullURL, func(req *http.Request) (*http.Response, error) { + defer req.Body.Close() + + bodyBytes, err := io.ReadAll(req.Body) + assert.NoError(t, err) + + var got []tokenRequest + err = json.Unmarshal(bodyBytes, &got) + assert.NoError(t, err) + + // Expect exactly one request: system=sonar, scope=defaultScope + if assert.Len(t, got, 1) { + assert.Equal(t, "sonar", got[0].System) + assert.Equal(t, defaultScope, got[0].Scope) + } + + return httpmock.NewStringResponse(200, mockSingleTokenResponse), nil + }) client := &piperhttp.Client{} client.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true}) @@ -46,30 +66,63 @@ func TestSystemTrust(t *testing.T) { assert.Equal(t, mockSonarToken, token) }) - t.Run("Get multiple tokens - happy path", func(t *testing.T) { - httpmock.RegisterResponder(http.MethodGet, testFullURL+"sonar,blackduck", httpmock.NewStringResponder(200, mockTwoTokensResponse)) + t.Run("Get multiple tokens - happy path (POST + JSON array body)", func(t *testing.T) { + httpmock.RegisterResponder(http.MethodPost, testFullURL, func(req *http.Request) (*http.Response, error) { + defer req.Body.Close() + + bodyBytes, err := io.ReadAll(req.Body) + assert.NoError(t, err) + + var got []tokenRequest + err = json.Unmarshal(bodyBytes, &got) + assert.NoError(t, err) + + // Expect two requests in any order + assert.Len(t, got, 2) + + seen := map[string]string{} + for _, r := range got { + seen[r.System] = r.Scope + } + assert.Equal(t, defaultScope, seen["sonar"]) + assert.Equal(t, defaultScope, seen["blackduck"]) + + return httpmock.NewStringResponse(200, mockTwoTokensResponse), nil + }) client := &piperhttp.Client{} client.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true}) - secrets, err := getSecrets([]string{"sonar", "blackduck"}, client, systemTrustConfiguration) + secrets, err := getSecrets(client, systemTrustConfiguration, + refNameToTokenBody("sonar"), + refNameToTokenBody("blackduck"), + ) assert.NoError(t, err) assert.Len(t, secrets, 2) + for _, s := range secrets { - switch system := s.System; system { + switch s.System { case "sonar": assert.Equal(t, mockSonarToken, s.Token) case "blackduck": assert.Equal(t, mockblackduckToken, s.Token) - default: - continue } } }) - t.Run("Get Sonar token - 403 error", func(t *testing.T) { - httpmock.RegisterResponder(http.MethodGet, testFullURL+"sonar", httpmock.NewStringResponder(403, errorMsg403)) + t.Run("refNameToTokenBody parses marker", func(t *testing.T) { + req := refNameToTokenBody("github-apppipeline-ghas") + assert.Equal(t, "github-app", req.System) + assert.Equal(t, "pipeline-ghas", req.Scope) + + req2 := refNameToTokenBody("sonar") + assert.Equal(t, "sonar", req2.System) + assert.Equal(t, defaultScope, req2.Scope) + }) + + t.Run("Get Sonar token - 403 error (POST)", func(t *testing.T) { + httpmock.RegisterResponder(http.MethodPost, testFullURL, httpmock.NewStringResponder(403, errorMsg403)) client := &piperhttp.Client{} client.SetOptions(piperhttp.ClientOptions{MaxRetries: -1, UseDefaultTransport: true}) @@ -77,5 +130,4 @@ func TestSystemTrust(t *testing.T) { _, err := GetToken("sonar", client, systemTrustConfiguration) assert.Error(t, err) }) - }