Skip to content

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

Merged
merged 1 commit into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions pkg/internal/cyberark/servicediscovery/discovery.go
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
}
153 changes: 153 additions & 0 deletions pkg/internal/cyberark/servicediscovery/discovery_test.go
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
}
Copy link
Member

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:

$ curl -v https://platform-discovery.integration-cyberark.cloud/api/v2/services/asd | jq
...
> GET /api/v2/services/asd HTTP/2
...
< HTTP/2 400
...
{
  "error": "input validation error"
}

Copy link
Contributor Author

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!


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)
}
})
}
}
3 changes: 3 additions & 0 deletions pkg/internal/cyberark/servicediscovery/testdata/README.md
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to know how to recreate or update this data.
I tried using curl , but got an empty response.

$ curl https://platform-discovery.integration-cyberark.cloud/api/v2/services/subdomain/venafi-test; echo
{}

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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: curl -v https://platform-discovery.integration-cyberark.cloud/api/v2/services/subdomain/beyonceknowles

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"}}