mirror of
https://github.com/SAP/jenkins-library.git
synced 2026-06-19 22:58:55 +02:00
feat: update system trust to support scope (#5639)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user