Skip to content

Commit 819cd69

Browse files
committed
feat: add client for cyberark service discovery
Signed-off-by: Ashley Davis <ashley.davis@cyberark.com>
1 parent c77ff2d commit 819cd69

File tree

4 files changed

+289
-0
lines changed

4 files changed

+289
-0
lines changed
+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package cyberarkdiscovery
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"time"
11+
12+
"github.com/jetstack/preflight/pkg/version"
13+
)
14+
15+
const (
16+
prodDiscoveryEndpoint = "https://platform-discovery.cyberark.cloud/api/v2/"
17+
integrationDiscoveryEndpoint = "https://platform-discovery.integration-cyberark.cloud/api/v2/"
18+
19+
// identityServiceName is the name of the identity service we're looking for in responses from the Service Discovery API
20+
// TODO: confirm that this is definitely the one to use
21+
identityServiceName = "identity_user_portal"
22+
23+
// maxDiscoverBodySize is the maximum allowed size for a response body from the CyberArk Service Discovery subdomain endpoint
24+
// As of 2025-04-16, a response from the integration environment is ~4kB
25+
maxDiscoverBodySize = 2 * 1024 * 1024
26+
)
27+
28+
// DiscoveryClient is a Golang client for interacting with the CyberArk Discovery Service. It allows
29+
// users to fetch URLs for various APIs available in CyberArk. This client is specialised to
30+
// fetch only API endpoints, since only API endpoints are required by the Venafi Kubernetes Agent currently.
31+
type DiscoveryClient struct {
32+
client *http.Client
33+
endpoint string
34+
}
35+
36+
// ClientOpt allows configuration of a DiscoveryClient when using New
37+
type ClientOpt func(*DiscoveryClient)
38+
39+
// WithHTTPClient allows the user to specify a custom HTTP client for the discovery client
40+
func WithHTTPClient(httpClient *http.Client) ClientOpt {
41+
return func(dc *DiscoveryClient) {
42+
dc.client = httpClient
43+
}
44+
}
45+
46+
// WithIntegrationEndpoint sets the discovery client to use the integration testing endpoint rather than production
47+
func WithIntegrationEndpoint() ClientOpt {
48+
return func(dc *DiscoveryClient) {
49+
dc.endpoint = integrationDiscoveryEndpoint
50+
}
51+
}
52+
53+
// WithCustomEndpoint sets the endpoint to a custom URL without checking that the URL is a CyberArk Service Discovery
54+
// server.
55+
func WithCustomEndpoint(endpoint string) ClientOpt {
56+
return func(dc *DiscoveryClient) {
57+
dc.endpoint = endpoint
58+
}
59+
}
60+
61+
// New creates a new CyberArk Service Discovery client, configurable with ClientOpt
62+
func New(clientOpts ...ClientOpt) *DiscoveryClient {
63+
client := &DiscoveryClient{
64+
client: &http.Client{
65+
Timeout: 10 * time.Second,
66+
},
67+
endpoint: prodDiscoveryEndpoint,
68+
}
69+
70+
for _, opt := range clientOpts {
71+
opt(client)
72+
}
73+
74+
return client
75+
}
76+
77+
// DiscoverIdentityAPIURL fetches from the service discovery service for a given subdomain
78+
// and parses the CyberArk Identity API URL.
79+
func (dc *DiscoveryClient) DiscoverIdentityAPIURL(ctx context.Context, subdomain string) (string, error) {
80+
endpoint, err := url.JoinPath(dc.endpoint, "services", "subdomain", subdomain)
81+
if err != nil {
82+
return "", fmt.Errorf("failed to build a valid URL for subdomain %s; possibly an invalid endpoint: %s", subdomain, err)
83+
}
84+
85+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
86+
if err != nil {
87+
return "", fmt.Errorf("failed to initialise request to %s: %s", endpoint, endpoint)
88+
}
89+
90+
request.Header.Set("Accept", "application/json")
91+
version.SetUserAgent(request)
92+
93+
resp, err := dc.client.Do(request)
94+
if err != nil {
95+
return "", fmt.Errorf("failed to perform HTTP request: %s", err)
96+
}
97+
98+
defer resp.Body.Close()
99+
100+
if resp.StatusCode != 200 {
101+
// a 404 error is returned with an empty JSON body "{}" if the subdomain is unknown; at the time of writing, we haven't observed
102+
// any other errors and so we can't special case them
103+
if resp.StatusCode == 404 {
104+
return "", fmt.Errorf("got an HTTP 404 response from service discovery; maybe the subdomain %q is incorrect or does not exist?", subdomain)
105+
}
106+
107+
return "", fmt.Errorf("got unexpected status code %s from request to service discovery API", resp.Status)
108+
}
109+
110+
type ServiceEndpoint struct {
111+
API string `json:"api"`
112+
// NB: other fields are intentionally ignored here; we only care about the API URL
113+
}
114+
115+
decodedResponse := make(map[string]ServiceEndpoint)
116+
117+
err = json.NewDecoder(io.LimitReader(resp.Body, maxDiscoverBodySize)).Decode(&decodedResponse)
118+
if err != nil {
119+
if err == io.ErrUnexpectedEOF {
120+
return "", fmt.Errorf("rejecting JSON response from server as it was too large or was truncated")
121+
}
122+
123+
return "", fmt.Errorf("failed to parse JSON from otherwise successful request to service discovery endpoint: %s", err)
124+
}
125+
126+
identityService, ok := decodedResponse[identityServiceName]
127+
if !ok {
128+
return "", fmt.Errorf("didn't find %s in response from service discovery; unable to detect CyberArk Identity API URL", identityServiceName)
129+
}
130+
131+
return identityService.API, nil
132+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package cyberarkdiscovery
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
_ "embed"
7+
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
15+
"github.com/jetstack/preflight/pkg/version"
16+
)
17+
18+
//go:embed testdata/discovery_success.json
19+
var discoverySuccessResponse string
20+
21+
func testHandler(w http.ResponseWriter, r *http.Request) {
22+
if r.Method != http.MethodGet {
23+
// This was observed by making a POST request to the integration environment
24+
// Normally, we'd expect 405 Method Not Allowed but we match the observed response here
25+
w.WriteHeader(http.StatusForbidden)
26+
_, _ = w.Write([]byte(`{"message":"Missing Authentication Token"}`))
27+
return
28+
}
29+
30+
if !strings.HasPrefix(r.URL.String(), "/services/subdomain/") {
31+
// This was observed by making a request to /api/v2/services/asd
32+
// Normally, we'd expect 404 Not Found but we match the observed response here
33+
w.WriteHeader(http.StatusForbidden)
34+
_, _ = w.Write([]byte(`{"message":"Missing Authentication Token"}`))
35+
return
36+
}
37+
38+
if r.Header.Get("User-Agent") != version.UserAgent() {
39+
w.WriteHeader(http.StatusInternalServerError)
40+
_, _ = w.Write([]byte("should set user agent on all requests"))
41+
return
42+
}
43+
44+
if r.Header.Get("Accept") != "application/json" {
45+
w.WriteHeader(http.StatusInternalServerError)
46+
_, _ = w.Write([]byte("should request JSON on all requests"))
47+
return
48+
}
49+
50+
subdomain := strings.TrimPrefix(r.URL.String(), "/services/subdomain/")
51+
52+
switch subdomain {
53+
case "venafi-test":
54+
_, _ = w.Write([]byte(discoverySuccessResponse))
55+
56+
case "no-identity":
57+
// return a snippet of valid service discovery JSON, but don't include the identity service
58+
_, _ = 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"}}`))
59+
60+
case "bad-request":
61+
// test how the client handles a random unexpected response
62+
w.WriteHeader(http.StatusBadRequest)
63+
_, _ = w.Write([]byte("{}"))
64+
65+
case "json-invalid":
66+
// test that the client correctly rejects handles invalid JSON
67+
w.WriteHeader(http.StatusOK)
68+
_, _ = w.Write([]byte(`{"a": a}`))
69+
70+
case "json-too-long":
71+
// test that the client correctly rejects JSON which is too long
72+
w.WriteHeader(http.StatusOK)
73+
74+
// we'll hex encode the random bytes (doubling the size)
75+
longData := make([]byte, 1+maxDiscoverBodySize/2)
76+
_, _ = rand.Read(longData)
77+
78+
longJSON, err := json.Marshal(map[string]string{"key": hex.EncodeToString(longData)})
79+
if err != nil {
80+
panic(err)
81+
}
82+
83+
_, _ = w.Write(longJSON)
84+
85+
default:
86+
w.WriteHeader(http.StatusNotFound)
87+
_, _ = w.Write([]byte("{}"))
88+
}
89+
}
90+
91+
func Test_DiscoverIdentityAPIURL(t *testing.T) {
92+
tests := map[string]struct {
93+
subdomain string
94+
expectedURL string
95+
expectedError error
96+
}{
97+
"successful request": {
98+
subdomain: "venafi-test",
99+
expectedURL: "https://ajp5871.id.integration-cyberark.cloud",
100+
expectedError: nil,
101+
},
102+
"subdomain not found": {
103+
subdomain: "something-random",
104+
expectedURL: "",
105+
expectedError: fmt.Errorf("got an HTTP 404 response from service discovery; maybe the subdomain %q is incorrect or does not exist?", "something-random"),
106+
},
107+
"no identity service in response": {
108+
subdomain: "no-identity",
109+
expectedURL: "",
110+
expectedError: fmt.Errorf("didn't find %s in response from service discovery; unable to detect CyberArk Identity API URL", identityServiceName),
111+
},
112+
"unexpected HTTP response": {
113+
subdomain: "bad-request",
114+
expectedURL: "",
115+
expectedError: fmt.Errorf("got unexpected status code 400 Bad Request from request to service discovery API"),
116+
},
117+
"response JSON too long": {
118+
subdomain: "json-too-long",
119+
expectedURL: "",
120+
expectedError: fmt.Errorf("rejecting JSON response from server as it was too large or was truncated"),
121+
},
122+
"response JSON invalid": {
123+
subdomain: "json-invalid",
124+
expectedURL: "",
125+
expectedError: fmt.Errorf("failed to parse JSON from otherwise successful request to service discovery endpoint: invalid character 'a' looking for beginning of value"),
126+
},
127+
}
128+
129+
for name, testSpec := range tests {
130+
t.Run(name, func(t *testing.T) {
131+
ctx := context.Background()
132+
133+
ts := httptest.NewServer(http.HandlerFunc(testHandler))
134+
135+
defer ts.Close()
136+
137+
client := New(WithCustomEndpoint(ts.URL))
138+
139+
apiURL, err := client.DiscoverIdentityAPIURL(ctx, testSpec.subdomain)
140+
if err != nil {
141+
if err.Error() != testSpec.expectedError.Error() {
142+
t.Errorf("expectedError=%v\nobservedError=%v", testSpec.expectedError, err)
143+
}
144+
}
145+
146+
// NB: we don't exit here because we also want to check the API URL is empty in the event of an error
147+
148+
if apiURL != testSpec.expectedURL {
149+
t.Errorf("expected API URL=%s\nobserved API URL=%s", testSpec.expectedURL, apiURL)
150+
}
151+
})
152+
}
153+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Test data for CyberArk Discovery
2+
3+
All data in this folder is derived from an unauthenticated endpoint accessible from the public internet.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}}

0 commit comments

Comments
 (0)