feat: update system trust to support scope (#5639)

This commit is contained in:
Timur Akhmadiev
2026-02-20 14:17:47 +02:00
committed by GitHub
parent 51bf11359f
commit cb1f24e32f
3 changed files with 173 additions and 46 deletions
+41 -5
View File
@@ -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()
}
+66 -27
View File
@@ -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 "<scope>", the value before "<scope>" is used as the system
// and the value after "<scope>" 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 = "<scope>"
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
}
+66 -14
View File
@@ -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 <scope> marker", func(t *testing.T) {
req := refNameToTokenBody("github-app<scope>pipeline-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)
})
}