From 92191649c6ccccc9bf405af4c48374cd89d2122b Mon Sep 17 00:00:00 2001 From: Ashley Davis Date: Wed, 16 Apr 2025 14:54:04 +0100 Subject: [PATCH] feat: add client for cyberark service discovery Signed-off-by: Ashley Davis --- .../cyberark/servicediscovery/discovery.go | 132 +++++++++++++++ .../servicediscovery/discovery_test.go | 153 ++++++++++++++++++ .../servicediscovery/testdata/README.md | 3 + .../testdata/discovery_success.json | 1 + 4 files changed, 289 insertions(+) create mode 100644 pkg/internal/cyberark/servicediscovery/discovery.go create mode 100644 pkg/internal/cyberark/servicediscovery/discovery_test.go create mode 100644 pkg/internal/cyberark/servicediscovery/testdata/README.md create mode 100644 pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json diff --git a/pkg/internal/cyberark/servicediscovery/discovery.go b/pkg/internal/cyberark/servicediscovery/discovery.go new file mode 100644 index 00000000..3e6b658d --- /dev/null +++ b/pkg/internal/cyberark/servicediscovery/discovery.go @@ -0,0 +1,132 @@ +package servicediscovery + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/jetstack/preflight/pkg/version" +) + +const ( + prodDiscoveryEndpoint = "https://platform-discovery.cyberark.cloud/api/v2/" + integrationDiscoveryEndpoint = "https://platform-discovery.integration-cyberark.cloud/api/v2/" + + // identityServiceName is the name of the identity service we're looking for in responses from the Service Discovery API + // We were told to use the identity_administration field, not the identity_user_portal field. + identityServiceName = "identity_administration" + + // maxDiscoverBodySize is the maximum allowed size for a response body from the CyberArk Service Discovery subdomain endpoint + // As of 2025-04-16, a response from the integration environment is ~4kB + maxDiscoverBodySize = 2 * 1024 * 1024 +) + +// Client is a Golang client for interacting with the CyberArk Discovery Service. It allows +// users to fetch URLs for various APIs available in CyberArk. This client is specialised to +// fetch only API endpoints, since only API endpoints are required by the Venafi Kubernetes Agent currently. +type Client struct { + client *http.Client + endpoint string +} + +// ClientOpt allows configuration of a Client when using New +type ClientOpt func(*Client) + +// WithHTTPClient allows the user to specify a custom HTTP client for the discovery client +func WithHTTPClient(httpClient *http.Client) ClientOpt { + return func(c *Client) { + c.client = httpClient + } +} + +// WithIntegrationEndpoint sets the discovery client to use the integration testing endpoint rather than production +func WithIntegrationEndpoint() ClientOpt { + return func(c *Client) { + c.endpoint = integrationDiscoveryEndpoint + } +} + +// WithCustomEndpoint sets the endpoint to a custom URL without checking that the URL is a CyberArk Service Discovery +// server. +func WithCustomEndpoint(endpoint string) ClientOpt { + return func(c *Client) { + c.endpoint = endpoint + } +} + +// New creates a new CyberArk Service Discovery client, configurable with ClientOpt +func New(clientOpts ...ClientOpt) *Client { + client := &Client{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + endpoint: prodDiscoveryEndpoint, + } + + for _, opt := range clientOpts { + opt(client) + } + + return client +} + +// DiscoverIdentityAPIURL fetches from the service discovery service for a given subdomain +// and parses the CyberArk Identity API URL. +func (c *Client) DiscoverIdentityAPIURL(ctx context.Context, subdomain string) (string, error) { + endpoint, err := url.JoinPath(c.endpoint, "services", "subdomain", subdomain) + if err != nil { + return "", fmt.Errorf("failed to build a valid URL for subdomain %s; possibly an invalid endpoint: %s", subdomain, err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("failed to initialise request to %s: %s", endpoint, err) + } + + request.Header.Set("Accept", "application/json") + version.SetUserAgent(request) + + resp, err := c.client.Do(request) + if err != nil { + return "", fmt.Errorf("failed to perform HTTP request: %s", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // a 404 error is returned with an empty JSON body "{}" if the subdomain is unknown; at the time of writing, we haven't observed + // any other errors and so we can't special case them + if resp.StatusCode == 404 { + return "", fmt.Errorf("got an HTTP 404 response from service discovery; maybe the subdomain %q is incorrect or does not exist?", subdomain) + } + + return "", fmt.Errorf("got unexpected status code %s from request to service discovery API", resp.Status) + } + + type ServiceEndpoint struct { + API string `json:"api"` + // NB: other fields are intentionally ignored here; we only care about the API URL + } + + decodedResponse := make(map[string]ServiceEndpoint) + + err = json.NewDecoder(io.LimitReader(resp.Body, maxDiscoverBodySize)).Decode(&decodedResponse) + if err != nil { + if err == io.ErrUnexpectedEOF { + return "", fmt.Errorf("rejecting JSON response from server as it was too large or was truncated") + } + + return "", fmt.Errorf("failed to parse JSON from otherwise successful request to service discovery endpoint: %s", err) + } + + identityService, ok := decodedResponse[identityServiceName] + if !ok { + return "", fmt.Errorf("didn't find %s in service discovery response, which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", identityServiceName) + } + + return identityService.API, nil +} diff --git a/pkg/internal/cyberark/servicediscovery/discovery_test.go b/pkg/internal/cyberark/servicediscovery/discovery_test.go new file mode 100644 index 00000000..bc2b5e66 --- /dev/null +++ b/pkg/internal/cyberark/servicediscovery/discovery_test.go @@ -0,0 +1,153 @@ +package servicediscovery + +import ( + "context" + "crypto/rand" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jetstack/preflight/pkg/version" +) + +//go:embed testdata/discovery_success.json +var discoverySuccessResponse string + +func testHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + // This was observed by making a POST request to the integration environment + // Normally, we'd expect 405 Method Not Allowed but we match the observed response here + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Missing Authentication Token"}`)) + return + } + + if !strings.HasPrefix(r.URL.String(), "/services/subdomain/") { + // This was observed by making a request to /api/v2/services/asd + // Normally, we'd expect 404 Not Found but we match the observed response here + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Missing Authentication Token"}`)) + return + } + + if r.Header.Get("User-Agent") != version.UserAgent() { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("should set user agent on all requests")) + return + } + + if r.Header.Get("Accept") != "application/json" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("should request JSON on all requests")) + return + } + + subdomain := strings.TrimPrefix(r.URL.String(), "/services/subdomain/") + + switch subdomain { + case "venafi-test": + _, _ = w.Write([]byte(discoverySuccessResponse)) + + case "no-identity": + // return a snippet of valid service discovery JSON, but don't include the identity service + _, _ = w.Write([]byte(`{"data_privacy": {"ui": "https://ui.dataprivacy.integration-cyberark.cloud/", "api": "https://us-east-1.dataprivacy.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-data_privacy.integration-cyberark.cloud", "region": "us-east-1"}}`)) + + case "bad-request": + // test how the client handles a random unexpected response + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("{}")) + + case "json-invalid": + // test that the client correctly rejects handles invalid JSON + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"a": a}`)) + + case "json-too-long": + // test that the client correctly rejects JSON which is too long + w.WriteHeader(http.StatusOK) + + // we'll hex encode the random bytes (doubling the size) + longData := make([]byte, 1+maxDiscoverBodySize/2) + _, _ = rand.Read(longData) + + longJSON, err := json.Marshal(map[string]string{"key": hex.EncodeToString(longData)}) + if err != nil { + panic(err) + } + + _, _ = w.Write(longJSON) + + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("{}")) + } +} + +func Test_DiscoverIdentityAPIURL(t *testing.T) { + tests := map[string]struct { + subdomain string + expectedURL string + expectedError error + }{ + "successful request": { + subdomain: "venafi-test", + expectedURL: "https://ajp5871.id.integration-cyberark.cloud", + expectedError: nil, + }, + "subdomain not found": { + subdomain: "something-random", + expectedURL: "", + expectedError: fmt.Errorf("got an HTTP 404 response from service discovery; maybe the subdomain %q is incorrect or does not exist?", "something-random"), + }, + "no identity service in response": { + subdomain: "no-identity", + expectedURL: "", + expectedError: fmt.Errorf("didn't find %s in service discovery response, which may indicate a suspended tenant; unable to detect CyberArk Identity API URL", identityServiceName), + }, + "unexpected HTTP response": { + subdomain: "bad-request", + expectedURL: "", + expectedError: fmt.Errorf("got unexpected status code 400 Bad Request from request to service discovery API"), + }, + "response JSON too long": { + subdomain: "json-too-long", + expectedURL: "", + expectedError: fmt.Errorf("rejecting JSON response from server as it was too large or was truncated"), + }, + "response JSON invalid": { + subdomain: "json-invalid", + expectedURL: "", + expectedError: fmt.Errorf("failed to parse JSON from otherwise successful request to service discovery endpoint: invalid character 'a' looking for beginning of value"), + }, + } + + for name, testSpec := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + ts := httptest.NewServer(http.HandlerFunc(testHandler)) + + defer ts.Close() + + client := New(WithCustomEndpoint(ts.URL)) + + apiURL, err := client.DiscoverIdentityAPIURL(ctx, testSpec.subdomain) + if err != nil { + if err.Error() != testSpec.expectedError.Error() { + t.Errorf("expectedError=%v\nobservedError=%v", testSpec.expectedError, err) + } + } + + // NB: we don't exit here because we also want to check the API URL is empty in the event of an error + + if apiURL != testSpec.expectedURL { + t.Errorf("expected API URL=%s\nobserved API URL=%s", testSpec.expectedURL, apiURL) + } + }) + } +} diff --git a/pkg/internal/cyberark/servicediscovery/testdata/README.md b/pkg/internal/cyberark/servicediscovery/testdata/README.md new file mode 100644 index 00000000..95e0b7a4 --- /dev/null +++ b/pkg/internal/cyberark/servicediscovery/testdata/README.md @@ -0,0 +1,3 @@ +# Test data for CyberArk Discovery + +All data in this folder is derived from an unauthenticated endpoint accessible from the public internet. diff --git a/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json b/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json new file mode 100644 index 00000000..81d69668 --- /dev/null +++ b/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json @@ -0,0 +1 @@ +{"data_privacy": {"ui": "https://ui.dataprivacy.integration-cyberark.cloud/", "api": "https://us-east-1.dataprivacy.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-data_privacy.integration-cyberark.cloud", "region": "us-east-1"}, "secrets_manager": {"ui": "https://ui.test-conjur.cloud", "api": "https://venafi-test.secretsmgr.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-secrets_manager.integration-cyberark.cloud", "region": "us-east-2"}, "idaptive_risk_analytics": {"ui": "https://ajp5871-my.analytics.idaptive.qa", "api": "https://ajp5871-my.analytics.idaptive.qa", "bootstrap": "https://venafi-test-idaptive_risk_analytics.integration-cyberark.cloud", "region": "US-East-Pod"}, "component_manager": {"ui": "https://ui-connectormanagement.connectormanagement.integration-cyberark.cloud", "api": "https://venafi-test.connectormanagement.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-component_manager.integration-cyberark.cloud", "region": "us-east-1"}, "recording": {"ui": "https://us-east-1.rec-ui.recording.integration-cyberark.cloud", "api": "https://venafi-test.recording.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-recording.integration-cyberark.cloud", "region": "us-east-1"}, "identity_user_portal": {"ui": "https://ajp5871.id.integration-cyberark.cloud", "api": "https://ajp5871.id.integration-cyberark.cloud", "bootstrap": "https://venafi-test-identity_user_portal.integration-cyberark.cloud/my", "region": "US-East-Pod"}, "userportal": {"ui": "https://us-east-1.ui.userportal.integration-cyberark.cloud/", "api": "https://venafi-test.api.userportal.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-userportal.integration-cyberark.cloud", "region": "us-east-1"}, "cloud_onboarding": {"ui": "https://ui-cloudonboarding.cloudonboarding.integration-cyberark.cloud/", "api": "https://venafi-test.cloudonboarding.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-cloud_onboarding.integration-cyberark.cloud", "region": "us-east-1"}, "identity_administration": {"ui": "https://ajp5871.id.integration-cyberark.cloud", "api": "https://ajp5871.id.integration-cyberark.cloud", "bootstrap": "https://venafi-test-identity_administration.integration-cyberark.cloud/admin", "region": "US-East-Pod"}, "adminportal": {"ui": "https://ui-adminportal.adminportal.integration-cyberark.cloud", "api": "https://venafi-test.adminportal.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-adminportal.integration-cyberark.cloud", "region": "us-east-1"}, "analytics": {"ui": "https://venafi-test.analytics.integration-cyberark.cloud/", "api": "https://venafi-test.analytics.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-analytics.integration-cyberark.cloud", "region": "us-east-1"}, "session_monitoring": {"ui": "https://us-east-1.sm-ui.sessionmonitoring.integration-cyberark.cloud", "api": "https://venafi-test.sessionmonitoring.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-session_monitoring.integration-cyberark.cloud", "region": "us-east-1"}, "audit": {"ui": "https://ui.audit-ui.integration-cyberark.cloud", "api": "https://venafi-test.audit.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-audit.integration-cyberark.cloud", "region": "us-east-1"}, "fmcdp": {"ui": "https://tagtig.io/", "api": "https://tagtig.io/api", "bootstrap": "https://venafi-test-fmcdp.integration-cyberark.cloud", "region": "us-east-1"}, "featureadopt": {"ui": "https://ui-featureadopt.featureadopt.integration-cyberark.cloud/", "api": "https://us-east-1-featureadopt.featureadopt.integration-cyberark.cloud/api", "bootstrap": "https://venafi-test-featureadopt.integration-cyberark.cloud", "region": "us-east-1"}} \ No newline at end of file