-
Notifications
You must be signed in to change notification settings - Fork 25
feat: add client for cyberark service discovery #646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like to know how to recreate or update this data.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That tenant was decommissioned and replaced with a new one I'm using to test the Identity client. There's a different example tenant which can be used for getting this kind of data: I didn't want to add the old one or the one above to that README because I don't know if they're intended to be long lived, so my instinct is to avoid referring to either in code right now! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I get a different response when I use curl to GET that endpoint:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah that's my fault for just making up a random identifier; I'll update this in the Identity client which is to follow!