diff --git a/LICENSES b/LICENSES index 17e347fc..fa942ea9 100644 --- a/LICENSES +++ b/LICENSES @@ -6,7 +6,7 @@ github.com/asaskevich/govalidator,https://github.com/asaskevich/govalidator/blob github.com/aymerick/douceur,https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE,MIT github.com/beorn7/perks/quantile,https://github.com/beorn7/perks/blob/v1.0.1/LICENSE,MIT github.com/blang/semver/v4,https://github.com/blang/semver/blob/v4.0.0/v4/LICENSE,MIT -github.com/cenkalti/backoff,https://github.com/cenkalti/backoff/blob/v2.2.1/LICENSE,MIT +github.com/cenkalti/backoff/v5,https://github.com/cenkalti/backoff/blob/v5.0.2/LICENSE,MIT github.com/cespare/xxhash/v2,https://github.com/cespare/xxhash/blob/v2.3.0/LICENSE.txt,MIT github.com/davecgh/go-spew/spew,https://github.com/davecgh/go-spew/blob/d8f796af33cc/LICENSE,ISC github.com/emicklei/go-restful/v3,https://github.com/emicklei/go-restful/blob/v3.11.2/LICENSE,MIT diff --git a/go.mod b/go.mod index 6a395e76..add1e194 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.4 require ( github.com/Venafi/vcert/v5 v5.8.1 - github.com/cenkalti/backoff v2.2.1+incompatible + github.com/cenkalti/backoff/v5 v5.0.2 github.com/d4l3k/messagediff v1.2.1 github.com/fatih/color v1.17.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 7dbbb962..fc9b2633 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= diff --git a/pkg/agent/run.go b/pkg/agent/run.go index 495cff08..59cc79dd 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/cenkalti/backoff" + "github.com/cenkalti/backoff/v5" "github.com/go-logr/logr" "github.com/hashicorp/go-multierror" "github.com/prometheus/client_golang/prometheus" @@ -330,14 +330,17 @@ func gatherAndOutputData(ctx context.Context, eventf Eventf, config CombinedConf backOff := backoff.NewExponentialBackOff() backOff.InitialInterval = 30 * time.Second backOff.MaxInterval = 3 * time.Minute - backOff.MaxElapsedTime = config.BackoffMaxTime - post := func() error { - return postData(klog.NewContext(ctx, log), config, preflightClient, readings) + + post := func() (any, error) { + return struct{}{}, postData(klog.NewContext(ctx, log), config, preflightClient, readings) } - err := backoff.RetryNotify(post, backOff, func(err error, t time.Duration) { + + notificationFunc := backoff.Notify(func(err error, t time.Duration) { eventf("Warning", "PushingErr", "retrying in %v after error: %s", t, err) log.Info("Warning: PushingErr: retrying", "in", t, "reason", err) }) + + _, err := backoff.Retry(ctx, post, backoff.WithBackOff(backOff), backoff.WithNotify(notificationFunc), backoff.WithMaxElapsedTime(config.BackoffMaxTime)) if err != nil { return fmt.Errorf("Exiting due to fatal error uploading: %v", err) } diff --git a/pkg/internal/cyberark/identity/advance_authentication_test.go b/pkg/internal/cyberark/identity/advance_authentication_test.go new file mode 100644 index 00000000..571f9674 --- /dev/null +++ b/pkg/internal/cyberark/identity/advance_authentication_test.go @@ -0,0 +1,151 @@ +package identity + +import ( + "context" + "fmt" + "testing" + + "github.com/jetstack/preflight/pkg/internal/cyberark/servicediscovery" +) + +func Test_IdentityAdvanceAuthentication(t *testing.T) { + tests := map[string]struct { + username string + password []byte + advanceBody advanceAuthenticationRequestBody + + expectedError error + }{ + "success": { + username: successUser, + password: []byte(successPassword), + advanceBody: advanceAuthenticationRequestBody{ + Action: ActionAnswer, + MechanismID: successMechanismID, + SessionID: successSessionID, + TenantID: "foo", + PersistantLogin: true, + }, + + expectedError: nil, + }, + "incorrect password": { + username: successUser, + password: []byte("foo"), + advanceBody: advanceAuthenticationRequestBody{ + Action: ActionAnswer, + MechanismID: successMechanismID, + SessionID: successSessionID, + TenantID: "foo", + PersistantLogin: true, + }, + + expectedError: fmt.Errorf(`got a failure response from request to advance authentication: message="Authentication (login or challenge) has failed. Please try again or contact your system administrator.", error="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:55555555555555555555555555555555"`), + }, + "bad action": { + username: successUser, + password: []byte(successPassword), + advanceBody: advanceAuthenticationRequestBody{ + Action: "foo", + MechanismID: successMechanismID, + SessionID: successSessionID, + TenantID: "foo", + PersistantLogin: true, + }, + + expectedError: fmt.Errorf(`got a failure response from request to advance authentication: message="Authentication (login or challenge) has failed. Please try again or contact your system administrator.", error="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:55555555555555555555555555555555"`), + }, + "bad mechanism id": { + username: successUser, + password: []byte(successPassword), + advanceBody: advanceAuthenticationRequestBody{ + Action: ActionAnswer, + MechanismID: "foo", + SessionID: successSessionID, + TenantID: "foo", + PersistantLogin: true, + }, + + expectedError: fmt.Errorf(`got a failure response from request to advance authentication: message="Authentication (login or challenge) has failed. Please try again or contact your system administrator.", error="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:55555555555555555555555555555555"`), + }, + "bad session id": { + username: successUser, + password: []byte(successPassword), + advanceBody: advanceAuthenticationRequestBody{ + Action: ActionAnswer, + MechanismID: successMechanismID, + SessionID: "foo", + TenantID: "foo", + PersistantLogin: true, + }, + + expectedError: fmt.Errorf(`got a failure response from request to advance authentication: message="Authentication (login or challenge) has failed. Please try again or contact your system administrator.", error="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:55555555555555555555555555555555"`), + }, + "persistant login not set": { + username: successUser, + password: []byte(successPassword), + advanceBody: advanceAuthenticationRequestBody{ + Action: ActionAnswer, + MechanismID: successMechanismID, + SessionID: successSessionID, + TenantID: "foo", + PersistantLogin: false, + }, + + expectedError: fmt.Errorf("got unexpected status code 403 Forbidden from request to advance authentication in CyberArk Identity API"), + }, + } + + for name, testSpec := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + identityServer := MockIdentityServer() + defer identityServer.Close() + + mockDiscoveryServer := servicediscovery.MockDiscoveryServerWithCustomAPIURL(identityServer.Server.URL) + defer mockDiscoveryServer.Close() + + discoveryClient := servicediscovery.New(servicediscovery.WithCustomEndpoint(mockDiscoveryServer.Server.URL)) + + client, err := NewWithDiscoveryClient(ctx, discoveryClient, servicediscovery.MockDiscoverySubdomain) + if err != nil { + t.Errorf("failed to create identity client: %s", err) + return + } + + err = client.doAdvanceAuthentication(ctx, testSpec.username, &testSpec.password, testSpec.advanceBody) + if testSpec.expectedError != err { + if testSpec.expectedError == nil { + t.Errorf("didn't expect an error but got %v", err) + return + } + + if err == nil { + t.Errorf("expected no error but got err=%v", testSpec.expectedError) + return + } + + if err.Error() != testSpec.expectedError.Error() { + t.Errorf("expected err=%v\nbut got err=%v", testSpec.expectedError, err) + return + } + } + + if testSpec.expectedError != nil { + return + } + + val, ok := client.tokenCache[testSpec.username] + + if !ok { + t.Errorf("expected token for %s to be set to %q but wasn't found", testSpec.username, mockSuccessfulStartAuthenticationToken) + return + } + + if val != mockSuccessfulStartAuthenticationToken { + t.Errorf("expected token for %s to be set to %q but was set to %q", testSpec.username, mockSuccessfulStartAuthenticationToken, val) + } + }) + } +} diff --git a/pkg/internal/cyberark/identity/cmd/testidentity/main.go b/pkg/internal/cyberark/identity/cmd/testidentity/main.go new file mode 100644 index 00000000..b7df3562 --- /dev/null +++ b/pkg/internal/cyberark/identity/cmd/testidentity/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + + "k8s.io/klog/v2" + + "github.com/jetstack/preflight/pkg/internal/cyberark/identity" + "github.com/jetstack/preflight/pkg/internal/cyberark/servicediscovery" +) + +// This is a trivial CLI application for testing our identity client end-to-end. +// It's not intended for distribution; it simply allows us to run our client and check +// the login is successful. + +const ( + subdomainFlag = "subdomain" + usernameFlag = "username" + passwordEnv = "TESTIDENTITY_PASSWORD" +) + +var ( + subdomain string + username string +) + +func run(ctx context.Context) error { + if subdomain == "" { + return fmt.Errorf("no %s flag provided", subdomainFlag) + } + + if username == "" { + return fmt.Errorf("no %s flag provided", usernameFlag) + } + + password := os.Getenv(passwordEnv) + if password == "" { + return fmt.Errorf("no password provided in %s", passwordEnv) + } + sdClient := servicediscovery.New(servicediscovery.WithIntegrationEndpoint()) + + client, err := identity.NewWithDiscoveryClient(ctx, sdClient, subdomain) + if err != nil { + return err + } + + err = client.LoginUsernamePassword(ctx, username, []byte(password)) + if err != nil { + return err + } + + return nil +} + +func main() { + defer klog.Flush() + + flagSet := flag.NewFlagSet("test", flag.ExitOnError) + klog.InitFlags(flagSet) + _ = flagSet.Parse([]string{"--v", "5"}) + + logger := klog.Background() + + ctx := klog.NewContext(context.Background(), logger) + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + flag.StringVar(&subdomain, subdomainFlag, "cert-manager", "The subdomain to use for service discovery") + flag.StringVar(&username, usernameFlag, "", + fmt.Sprintf("Username to log in with. Password should be provided via %s envvar", passwordEnv), + ) + + flag.Parse() + + errCode := 0 + + err := run(ctx) + if err != nil { + logger.Error(err, "execution failed") + errCode = 1 + } + + klog.FlushAndExit(klog.ExitFlushTimeout, errCode) +} diff --git a/pkg/internal/cyberark/identity/identity.go b/pkg/internal/cyberark/identity/identity.go new file mode 100644 index 00000000..d1b1ba77 --- /dev/null +++ b/pkg/internal/cyberark/identity/identity.go @@ -0,0 +1,467 @@ +package identity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/cenkalti/backoff/v5" + "k8s.io/klog/v2" + + "github.com/jetstack/preflight/pkg/internal/cyberark/servicediscovery" + "github.com/jetstack/preflight/pkg/logs" + "github.com/jetstack/preflight/pkg/version" +) + +const ( + // MechanismUsernamePassword is the string which identifies the username/password mechanism for completing + // a login attempt + MechanismUsernamePassword = "UP" + + // ActionAnswer is the string which is sent to an AdvanceAuthentication request to indicate we're providing + // the credentials in band in text format (i.e., we're sending a password) + ActionAnswer = "Answer" + + // SummaryLoginSuccess is returned by a StartAuthentication to indicate that login does not need + // to proceed to the AdvanceAuthentication step. + // We don't handle this because we don't expect it to happen. + SummaryLoginSuccess = "LoginSuccess" + + // SummaryNewPackage is returned by a StartAuthentication call when the user must complete a challenge + // to complete the log in. This is expected on a first login. + SummaryNewPackage = "NewPackage" + + // maxStartAuthenticationBodySize is the maximum allowed size for a response body from the CyberArk Identity + // StartAuthentication endpoint. + // As of 2025-04-30, a response from the integration environment is ~1kB + maxStartAuthenticationBodySize = 10 * 1024 + + // maxAdvanceAuthenticationBodySize is the maximum allowed size for a response body from the CyberArk Identity + // AdvanceAuthentication endpoint. + // As of 2025-04-30, a response from the integration environment is ~3kB + maxAdvanceAuthenticationBodySize = 30 * 1024 +) + +var ( + errNoUPMechanism = fmt.Errorf("found no authentication mechanism with the username + password type (%s); unable to complete login using this identity", MechanismUsernamePassword) +) + +// startAuthenticationRequestBody is the body sent to the StartAuthentication endpoint in CyberArk Identity; +// see https://api-docs.cyberark.com/docs/identity-api-reference/authentication-and-authorization/operations/create-a-security-start-authentication +type startAuthenticationRequestBody struct { + // TenantID is the internal ID of the tenant containing the user attempting to log in. In testing, + // it seems that the subdomain works in this field. + TenantID string `json:"TenantId"` + + // Version is set to 1.0 + Version string `json:"Version"` + + // User is the username of the user trying to log in. For a human, this is likely to be an email address. + User string `json:"User"` +} + +// identityResponseBody generically wraps a response from the Identity server; the Result will differ for +// responses from different endpoint, but the other fields are similar. +// Not all fields in the JSON returned from the server are replicated here, since we only need a subset. +type identityResponseBody[T any] struct { + // Success is a simple boolean indicator from the server of success. + // NB: The JSON key is lowercase, in contrast to other JSON keys in the response. + Success bool `json:"success"` + + // Result holds the information we need to parse from successful responses + Result T `json:"Result"` + + // Message holds an information message such as an error message. Experimentally it seems to be null + // for successful attempts. + Message string `json:"Message"` + + // ErrorID holds an error ID when something goes wrong with the call. + // Not to be confused with ErrorCode; for failure messages, we see ErrorID set and ErrorCode null. + ErrorID string `json:"ErrorID"` + + // NB: Other fields omitted since we don't need them +} + +// startAuthenticationResponseBody is the response returned by the server from a request to StartAuthentication. +type startAuthenticationResponseBody identityResponseBody[startAuthenticationResponseResult] + +// advanceAuthenticationResponseBody is the response from the AdvanceAuthentication endpoint. +type advanceAuthenticationResponseBody identityResponseBody[advanceAuthenticationResponseResult] + +// startAuthenticationResponseResult holds the important data we need to pass to AdvanceAuthentication +type startAuthenticationResponseResult struct { + // SessionID identifies this login attempt, and must be passed with the + // follow-up AdvanceAuthentication request. + SessionID string `json:"SessionId"` + + // Challenges provides a list of methods for logging in. We need to look + // for the correct login method we want to use, and then find the MechanismId + // for that login method to pass to the AdvanceAuthentication request. + Challenges []startAuthenticationChallenge `json:"Challenges"` + + // Summary indicates whether a StartAuthentication calls needs to be followed up with an AdvanceAuthentication + // call. From the docs: + // > If the user exists, the response contains a Summary of either LoginSuccess or NewPackage. + // > You receive LoginSuccess when the request includes an .ASPXAUTH cookie from prior successful authentication. + Summary string `json:"Summary"` +} + +// startAuthenticationChallenge is an entry in the array of MFA mechanisms; +// at least one MFA mechanism should be satisfied by the user. +type startAuthenticationChallenge struct { + Mechanisms []startAuthenticationMechanism `json:"Mechanisms"` +} + +// startAuthenticationMechanism holds details of a given mechanism for authenticating. +// This corresponds to "how" the user authenticates, e.g. via password or email, etc +type startAuthenticationMechanism struct { + // Name represents the name of the challenge mechanism. This is usually an upper-case + // string, such as "UP" for "username / password" + Name string `json:"Name"` + + // Enrolled is true if the given mechanism is available for the user attempting + // to authenticate. + Enrolled bool `json:"Enrolled"` + + // MechanismID uniquely identifies a particular mechanism, and must be passed + // to the AdvanceAuthentication request when authenticating. + MechanismID string `json:"MechanismId"` +} + +// advanceAuthenticationRequestBody is a request body for the AdvanceAuthentication call to CyberArk Identity, +// which should usually be obtained by making requests to StartAuthentication first. +// WARNING: This struct can hold secret data (a user's password) +type advanceAuthenticationRequestBody struct { + // Action is a string identifying how we're intending to log in; for username/password, this is + // set to "Answer" to indicate that the password is held in the Answer field + Action string `json:"Action"` + + // Answer holds the user's password to send to the server + // WARNING: THIS IS SECRET DATA. + Answer string `json:"Answer"` + + // MechanismID identifies the login mechanism and must be retrieved from a call to StartAuthentication + MechanismID string `json:"MechanismId"` + + // SessionID identifies the login session and must be retrieved from a call to StartAuthentication + SessionID string `json:"SessionId"` + + // TenantID identifies the tenant; this can be inferred from the URL if we used service discovery to + // get the Identity API URL, but we set it anyway to be explicit. + TenantID string `json:"TenantId"` + + // PersistantLogin is documented to "[indicate] whether the session should persist after the user + // closes the browser"; for service-to-service auth which we're trying to do, we set this to true. + PersistantLogin bool `json:"PersistantLogin"` +} + +// advanceAuthenticationResponseResult is the specific information returned for a successful AdvanceAuthentication call +type advanceAuthenticationResponseResult struct { + // Summary holds a "brief summary of the authentication outcome" + Summary string `json:"Summary"` + + // Token is the auth token we need to save; this is the result of the login + // process which can be sent as a bearer token to other services. + Token string `json:"Token"` + + // Other fields omitted as they're not needed +} + +// Client is an client for interacting with the CyberArk Identity API and performing a login using a username and password. +// For context on the behaviour of this client, see the Pytho SDK: https://github.com/cyberark/ark-sdk-python/blob/3be12c3f2d3a2d0407025028943e584b6edc5996/ark_sdk_python/auth/identity/ark_identity.py +type Client struct { + client *http.Client + + endpoint string + subdomain string + + tokenCache map[string]token + tokenCacheMutex sync.Mutex +} + +// token is a wrapper type for holding auth tokens we want to cache. +type token string + +// New returns an initialized CyberArk Identity client using a default service discovery client. +// NB: This function performs service discovery when called, in order to ensure that all Identity +// clients are created with a valid Identity API URL. This function blocks on the network call to +// the discovery service. +func New(ctx context.Context, subdomain string) (*Client, error) { + return NewWithDiscoveryClient(ctx, servicediscovery.New(), subdomain) +} + +// NewWithDiscoveryClient returns an initialized CyberArk Identity client using the given service discovery client. +// NB: This function performs service discovery when called, in order to ensure that all Identity +// clients are created with a valid Identity API URL. This function blocks on the network call to +// the discovery service. +func NewWithDiscoveryClient(ctx context.Context, discoveryClient *servicediscovery.Client, subdomain string) (*Client, error) { + if discoveryClient == nil { + return nil, fmt.Errorf("must provide a non-nil discovery client to the Identity Client") + } + + endpoint, err := discoveryClient.DiscoverIdentityAPIURL(ctx, subdomain) + if err != nil { + return nil, err + } + + return &Client{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + + endpoint: endpoint, + subdomain: subdomain, + + tokenCache: make(map[string]token), + tokenCacheMutex: sync.Mutex{}, + }, nil +} + +// LoginUsernamePassword performs a blocking call to fetch an auth token from CyberArk Identity using the given username and password. +// The password is zeroed after use. +// Tokens are cached internally and are not directly accessible to code; use Client.AuthenticatedHTTPClient to add credentials +// to an *http.Client. +func (c *Client) LoginUsernamePassword(ctx context.Context, username string, password []byte) error { + defer func() { + for i := range password { + password[i] = 0x00 + } + }() + + operation := func() (any, error) { + advanceRequestBody, err := c.doStartAuthentication(ctx, username) + if err != nil { + return struct{}{}, err + } + + // NB: We explicitly pass advanceRequestBody by value here so that when we add the password + // in doAdvanceAuthentication we don't create a copy of the password slice elsewhere. + err = c.doAdvanceAuthentication(ctx, username, &password, advanceRequestBody) + if err != nil { + return struct{}{}, err + } + + return struct{}{}, nil + } + + backoffPolicy := backoff.NewConstantBackOff(10 * time.Second) + + _, err := backoff.Retry(ctx, operation, backoff.WithBackOff(backoffPolicy)) + + return err +} + +// doStartAuthentication performs the initial request to start the login process using a username and password. +// It returns a partially initialized advanceAuthenticationRequestBody ready to send to the server to complete +// the login. As this function doesn't have access to the password, it must be added to the returned request body +// by the caller before being used as a request to AdvanceAuthentication. +// See https://api-docs.cyberark.com/docs/identity-api-reference/authentication-and-authorization/operations/create-a-security-start-authentication +func (c *Client) doStartAuthentication(ctx context.Context, username string) (advanceAuthenticationRequestBody, error) { + response := advanceAuthenticationRequestBody{} + + logger := klog.FromContext(ctx).WithValues("source", "Identity.doStartAuthentication") + + body := startAuthenticationRequestBody{ + Version: "1.0", // this is the only value in the docs + + TenantID: c.subdomain, + + User: username, + } + + bodyJSON, err := json.Marshal(body) + if err != nil { + return response, fmt.Errorf("failed to marshal JSON for request to StartAuthentication endpoint: %s", err) + } + + endpoint, err := url.JoinPath(c.endpoint, "Security", "StartAuthentication") + if err != nil { + return response, fmt.Errorf("failed to create URL for request to CyberArk Identity StartAuthentication: %s", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) + if err != nil { + return response, fmt.Errorf("failed to initialise request to Identity endpoint %s: %s", endpoint, err) + } + + setIdentityHeaders(request) + + httpResponse, err := c.client.Do(request) + if err != nil { + return response, fmt.Errorf("failed to perform HTTP request to start authentication: %s", err) + } + + defer httpResponse.Body.Close() + + if httpResponse.StatusCode != 200 { + err := fmt.Errorf("got unexpected status code %s from request to start authentication in CyberArk Identity API", httpResponse.Status) + if httpResponse.StatusCode >= 500 || httpResponse.StatusCode < 400 { + return response, err + } + + // If we got a 4xx error, we shouldn't retry + return response, backoff.Permanent(err) + + } + + startAuthResponse := startAuthenticationResponseBody{} + + err = json.NewDecoder(io.LimitReader(httpResponse.Body, maxStartAuthenticationBodySize)).Decode(&startAuthResponse) + if err != nil { + if err == io.ErrUnexpectedEOF { + return response, fmt.Errorf("rejecting JSON response from server as it was too large or was truncated") + } + + return response, fmt.Errorf("failed to parse JSON from otherwise successful request to start authentication: %s", err) + } + + if !startAuthResponse.Success { + return response, fmt.Errorf("got a failure response from request to start authentication: message=%q, error=%q", startAuthResponse.Message, startAuthResponse.ErrorID) + } + + logger.V(logs.Debug).Info("made successful request to StartAuthentication", "summary", startAuthResponse.Result.Summary) + + if startAuthResponse.Result.Summary != SummaryNewPackage { + // This means we can't respond to whatever summary the server sent. + // The best thing to do is try and find a challenge we can solve anyway. + klog.FromContext(ctx).Info("got an unexpected Summary from StartAuthentication response; will attempt to complete a login challenge anyway", "summary", startAuthResponse.Result.Summary) + } + + // We can only handle a UP type challenge, and if there are any other challenges, we'll have to fail because we can't handle them. + // https://github.com/cyberark/ark-sdk-python/blob/3be12c3f2d3a2d0407025028943e584b6edc5996/ark_sdk_python/auth/identity/ark_identity.py#L405 + switch len(startAuthResponse.Result.Challenges) { + case 0: + return response, fmt.Errorf("got no valid challenges in response to start authentication; unable to log in") + + case 1: + // do nothing, this is ideal + + default: + return response, fmt.Errorf("got %d challenges in response to start authentication, which means MFA may be enabled; unable to log in", len(startAuthResponse.Result.Challenges)) + } + + challenge := startAuthResponse.Result.Challenges[0] + + switch len(challenge.Mechanisms) { + case 0: + // presumably this shouldn't happen, but handle the case anyway + return response, fmt.Errorf("got no mechanisms for challenge from Identity server") + + case 1: + // do nothing, this is ideal + + default: + return response, fmt.Errorf("got %d mechanisms in response to start authentication, which means MFA may be enabled; unable to log in", len(challenge.Mechanisms)) + } + + mechanism := challenge.Mechanisms[0] + + if !mechanism.Enrolled || mechanism.Name != MechanismUsernamePassword { + return response, errNoUPMechanism + } + + response.Action = ActionAnswer + response.MechanismID = mechanism.MechanismID + response.SessionID = startAuthResponse.Result.SessionID + response.TenantID = c.subdomain + response.PersistantLogin = true + + return response, nil +} + +// doAdvanceAuthentication performs the second step of the login process, sending the password to the server +// and receiving a token in response. +func (c *Client) doAdvanceAuthentication(ctx context.Context, username string, password *[]byte, requestBody advanceAuthenticationRequestBody) error { + if password == nil { + return backoff.Permanent(fmt.Errorf("password must not be nil; this is a programming error")) + } + + requestBody.Answer = string(*password) + + bodyJSON, err := json.Marshal(requestBody) + if err != nil { + return backoff.Permanent(fmt.Errorf("failed to marshal JSON for request to AdvanceAuthentication endpoint: %s", err)) + } + + endpoint, err := url.JoinPath(c.endpoint, "Security", "AdvanceAuthentication") + if err != nil { + return backoff.Permanent(fmt.Errorf("failed to create URL for request to CyberArk Identity AdvanceAuthentication: %s", err)) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) + if err != nil { + return fmt.Errorf("failed to initialise request to Identity endpoint %s: %s", endpoint, err) + } + + setIdentityHeaders(request) + + httpResponse, err := c.client.Do(request) + if err != nil { + return fmt.Errorf("failed to perform HTTP request to advance authentication: %s", err) + } + + defer httpResponse.Body.Close() + + // Important: Even login failures can produce a 200 status code, so this + // check won't catch all failures + if httpResponse.StatusCode != 200 { + err := fmt.Errorf("got unexpected status code %s from request to advance authentication in CyberArk Identity API", httpResponse.Status) + if httpResponse.StatusCode >= 500 || httpResponse.StatusCode < 400 { + return err + } + + // If we got a 4xx error, we shouldn't retry + return backoff.Permanent(err) + } + + advanceAuthResponse := advanceAuthenticationResponseBody{} + + err = json.NewDecoder(io.LimitReader(httpResponse.Body, maxAdvanceAuthenticationBodySize)).Decode(&advanceAuthResponse) + 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 advance authentication: %s", err) + } + + if !advanceAuthResponse.Success { + return fmt.Errorf("got a failure response from request to advance authentication: message=%q, error=%q", advanceAuthResponse.Message, advanceAuthResponse.ErrorID) + } + + if advanceAuthResponse.Result.Summary != SummaryLoginSuccess { + // IF MFA was enabled and we got here, there's probably nothing to be gained from a retry + // and the best thing to do is fail now so the user can fix MFA settings. + return backoff.Permanent(fmt.Errorf("got a %s response from AdvanceAuthentication; this implies that the user account %s requires MFA, which is not supported. Try unlocking MFA for this user", advanceAuthResponse.Result.Summary, username)) + } + + klog.FromContext(ctx).Info("successfully completed AdvanceAuthentication request to CyberArk Identity; login complete", "username", username) + + c.tokenCacheMutex.Lock() + + c.tokenCache[username] = token(advanceAuthResponse.Result.Token) + + c.tokenCacheMutex.Unlock() + + return nil +} + +// setIdentityHeaders sets the headers required for requests to the CyberArk Identity API. +// From the docs: +// Your request header must contain X-IDAP-NATIVE-CLIENT:true to indicate that an application is invoking +// the CyberArk Identity endpoint, and +// Content-Type: application/json to indicate that the body is in JSON format. +// Experimentally, it seems the X-IDAP-NATIVE-CLIENT is not required but we'll follow the docs. +func setIdentityHeaders(r *http.Request) { + // The "canonicalheader" linter warns us that the IDAP-NATIVE-CLIENT header isn't canonical, but we silence it here + // since we want to exactly match the docs. + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-IDAP-NATIVE-CLIENT", "true") //nolint: canonicalheader + version.SetUserAgent(r) +} diff --git a/pkg/internal/cyberark/identity/mock.go b/pkg/internal/cyberark/identity/mock.go new file mode 100644 index 00000000..cdcfe861 --- /dev/null +++ b/pkg/internal/cyberark/identity/mock.go @@ -0,0 +1,226 @@ +package identity + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/jetstack/preflight/pkg/version" + + _ "embed" +) + +const ( + successUser = "test@example.com" + successUserMultipleChallenges = "test-multiple-challenges@example.com" + successUserMultipleMechanisms = "test-multiple-mechanisms@example.com" + noUPMechanism = "noup@example.com" + + successMechanismID = "aaaaaaa_AAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111" + successSessionID = "mysessionid101" + successPassword = "somepassword" + + // mockSuccessfulStartAuthenticationToken is the token returned by the + // mock server in response to a successful AdvanceAuthentication request + // Must match what's in testdata/advance_authentication_success.json + mockSuccessfulStartAuthenticationToken = "long-token" +) + +var ( + //go:embed testdata/start_authentication_success.json + startAuthenticationSuccessResponse string + + //go:embed testdata/start_authentication_success_multiple_challenges.json + startAuthenticationSuccessMultipleChallengesResponse string + + //go:embed testdata/start_authentication_success_multiple_mechanisms.json + startAuthenticationSuccessMultipleMechanismsResponse string + + //go:embed testdata/start_authentication_success_no_up_mechanism.json + startAuthenticationNoUPMechanismResponse string + + //go:embed testdata/start_authentication_failure.json + startAuthenticationFailureResponse string + + //go:embed testdata/advance_authentication_success.json + advanceAuthenticationSuccessResponse string + + //go:embed testdata/advance_authentication_failure.json + advanceAuthenticationFailureResponse string +) + +type mockIdentityServer struct { + Server *httptest.Server +} + +// MockIdentityServer returns a mocked CyberArk Identity server. +// The returned server should be Closed by the caller after use. +func MockIdentityServer() *mockIdentityServer { + mis := &mockIdentityServer{} + + mis.Server = httptest.NewServer(mis) + + return mis +} + +func (mis *mockIdentityServer) Close() { + mis.Server.Close() +} + +func (mis *mockIdentityServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.String() { + case "/Security/StartAuthentication": + mis.handleStartAuthentication(w, r) + return + + case "/Security/AdvanceAuthentication": + mis.handleAdvanceAuthentication(w, r) + return + + default: + // The server returns an HTML page for this case, but that doesn't seem important for us to replicate + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + } +} + +func checkRequestHeaders(r *http.Request) error { + var errs []error + + if r.Header.Get("User-Agent") != version.UserAgent() { + errs = append(errs, fmt.Errorf("should set user agent on all requests")) + } + + if r.Header.Get("Content-Type") != "application/json" { + errs = append(errs, fmt.Errorf("should request JSON on all requests")) + } + + if r.Header.Get("X-IDAP-NATIVE-CLIENT") != "true" { //nolint: canonicalheader + errs = append(errs, fmt.Errorf("should set X-IDAP-NATIVE-CLIENT header to true on all requests")) + } + + return errors.Join(errs...) +} + +func (mis *mockIdentityServer) handleStartAuthentication(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + // Empirically we saw that a PUT and a DELETE request to this endpoint was actually successful, + // but the endpoint is documented to use POST so we'll ensure that only that method is used. + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"endpoint is documented to only accept POST"}`)) + return + } + + if err := checkRequestHeaders(r); err != nil { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(fmt.Sprintf(`{"message":"issues with headers sent to mock server: %s"}`, err.Error()))) + return + } + + reqBody := startAuthenticationRequestBody{} + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&reqBody); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf(`{"message":"failed to unmarshal request body: %s"}`, err))) + return + } + + // Important: Experimentally, the Identity server we generated the testdata from seems to accept + // any user and provide a success response for StartAuthentication, so filtering on the user here + // doesn't match the server's behavior; however, it's helpful to do so since it lets us test different + // error handling capabilities in the client. + + switch reqBody.User { + case successUser: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(startAuthenticationSuccessResponse)) + + case successUserMultipleChallenges: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(startAuthenticationSuccessMultipleChallengesResponse)) + + case successUserMultipleMechanisms: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(startAuthenticationSuccessMultipleMechanismsResponse)) + + case noUPMechanism: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(startAuthenticationNoUPMechanismResponse)) + + case "": + // experimentally, this case produces a 200 response but a "failed" body + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(startAuthenticationFailureResponse)) + + default: + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"MOCK SERVER: invalid user"}`)) + } +} + +func (mis *mockIdentityServer) handleAdvanceAuthentication(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"endpoint is documented to only accept POST"}`)) + return + } + + if err := checkRequestHeaders(r); err != nil { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(fmt.Sprintf(`{"message":"issues with headers sent to mock server: %s"}`, err.Error()))) + } + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + advanceBody := &advanceAuthenticationRequestBody{} + + if err := decoder.Decode(&advanceBody); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf(`{"message":"failed to unmarshal request body: %s"}`, err))) + return + } + + // Important: The actual server will return 200 OK even if the login fails. + // Most failure responses should copy that. + + if !advanceBody.PersistantLogin { + // this is something we enforce but wouldn't actually be an error from + // a real server, so we return a different error here + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`expected PersistantLogin to be true`)) + return + } + + if advanceBody.SessionID != successSessionID { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(advanceAuthenticationFailureResponse)) + return + } + + if advanceBody.MechanismID != successMechanismID { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(advanceAuthenticationFailureResponse)) + return + } + + if advanceBody.Action != ActionAnswer { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(advanceAuthenticationFailureResponse)) + return + } + + if advanceBody.Answer != successPassword { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(advanceAuthenticationFailureResponse)) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(advanceAuthenticationSuccessResponse)) +} diff --git a/pkg/internal/cyberark/identity/start_authentication_test.go b/pkg/internal/cyberark/identity/start_authentication_test.go new file mode 100644 index 00000000..87dc2b3f --- /dev/null +++ b/pkg/internal/cyberark/identity/start_authentication_test.go @@ -0,0 +1,97 @@ +package identity + +import ( + "context" + "fmt" + "testing" + + "github.com/jetstack/preflight/pkg/internal/cyberark/servicediscovery" +) + +func Test_IdentityStartAuthentication(t *testing.T) { + tests := map[string]struct { + username string + + expectedError error + }{ + "successful request": { + username: successUser, + expectedError: nil, + }, + "successful request, multiple challenges": { + username: successUserMultipleChallenges, + expectedError: fmt.Errorf("got 2 challenges in response to start authentication, which means MFA may be enabled; unable to log in"), + }, + "successful request, multiple mechanisms": { + username: successUserMultipleMechanisms, + expectedError: fmt.Errorf("got 2 mechanisms in response to start authentication, which means MFA may be enabled; unable to log in"), + }, + "successful request, no username / password (UP) mechanism available": { + username: noUPMechanism, + expectedError: errNoUPMechanism, + }, + "failed request": { + // experimentally we've seen the failure response when passing an empty username + username: "", + expectedError: fmt.Errorf(`got a failure response from request to start authentication: message="Authentication (login or challenge) has failed. Please try again or contact your system administrator.", error="00000000-0400-4000-1111-222222222222:01234567890abcdef"`), + }, + } + + for name, testSpec := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + identityServer := MockIdentityServer() + defer identityServer.Close() + + mockDiscoveryServer := servicediscovery.MockDiscoveryServerWithCustomAPIURL(identityServer.Server.URL) + defer mockDiscoveryServer.Close() + + discoveryClient := servicediscovery.New(servicediscovery.WithCustomEndpoint(mockDiscoveryServer.Server.URL)) + + client, err := NewWithDiscoveryClient(ctx, discoveryClient, servicediscovery.MockDiscoverySubdomain) + if err != nil { + t.Errorf("failed to create identity client: %s", err) + return + } + + advanceBody, err := client.doStartAuthentication(ctx, testSpec.username) + if err != nil { + if testSpec.expectedError == nil { + t.Errorf("didn't expect an error but got %v", err) + return + } + + if err.Error() != testSpec.expectedError.Error() { + t.Errorf("expected err=%v\nbut got err=%v", testSpec.expectedError, err) + return + } + } + + if testSpec.expectedError != nil { + return + } + + if advanceBody.TenantID != client.subdomain { + t.Errorf("expected advanceAuthenticationRequestBody.TenantID to be %s but got %s", client.subdomain, advanceBody.TenantID) + } + + if advanceBody.SessionID != successSessionID { + t.Errorf("expected advanceAuthenticationRequestBody.SessionID to be %s but got %s", successSessionID, advanceBody.SessionID) + } + + if advanceBody.MechanismID != successMechanismID { + t.Errorf("expected advanceAuthenticationRequestBody.MechanismID to be %s but got %s", successMechanismID, advanceBody.MechanismID) + } + + if advanceBody.Action != ActionAnswer { + t.Errorf("expected advanceAuthenticationRequestBody.Action to be %s but got %s", ActionAnswer, advanceBody.Action) + } + + if !advanceBody.PersistantLogin { + t.Error("expected advanceAuthenticationRequestBody.PersistantLogin to be true but it wasn't") + } + + }) + } +} diff --git a/pkg/internal/cyberark/identity/testdata/advance_authentication_failure.json b/pkg/internal/cyberark/identity/testdata/advance_authentication_failure.json new file mode 100644 index 00000000..315c6497 --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/advance_authentication_failure.json @@ -0,0 +1,13 @@ +{ + "success": false, + "Result": { + "Summary": "Failure" + }, + "Message": "Authentication (login or challenge) has failed. Please try again or contact your system administrator.", + "MessageID": null, + "Exception": null, + "ErrorID": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:55555555555555555555555555555555", + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/advance_authentication_success.json b/pkg/internal/cyberark/identity/testdata/advance_authentication_success.json new file mode 100644 index 00000000..c35ed116 --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/advance_authentication_success.json @@ -0,0 +1,25 @@ +{ + "success": true, + "Result": { + "AuthLevel": "Normal", + "DisplayName": "Namey McNamerson", + "Token": "long-token", + "Auth": "auth-auth", + "UserId": "11111111-2222-3333-4444-555555555555", + "EmailAddress": "name@example.com", + "UserDirectory": "CDS", + "PodFqdn": "xxx0000.id.integration-cyberark.cloud", + "User": "name@example.org.111111", + "CustomerID": "XXX0000", + "SystemID": "XXX0000", + "SourceDsType": "CDS", + "Summary": "LoginSuccess" + }, + "Message": null, + "MessageID": null, + "Exception": null, + "ErrorID": null, + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/start_authentication_failure.json b/pkg/internal/cyberark/identity/testdata/start_authentication_failure.json new file mode 100644 index 00000000..426a560d --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/start_authentication_failure.json @@ -0,0 +1,13 @@ +{ + "success": false, + "Result": { + "Summary": "Undefined" + }, + "Message": "Authentication (login or challenge) has failed. Please try again or contact your system administrator.", + "MessageID": null, + "Exception": null, + "ErrorID": "00000000-0400-4000-1111-222222222222:01234567890abcdef", + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/start_authentication_success.json b/pkg/internal/cyberark/identity/testdata/start_authentication_success.json new file mode 100644 index 00000000..3a91b4d6 --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/start_authentication_success.json @@ -0,0 +1,40 @@ +{ + "success": true, + "Result": { + "ClientHints": { + "PersistDefault": false, + "AllowPersist": true, + "AllowForgotPassword": true, + "EndpointAuthenticationEnabled": false + }, + "Version": "1.0", + "SessionId": "mysessionid101", + "EventDescription": null, + "RetryWaitingTime": 0, + "SecurityImageName": null, + "AllowLoginMfaCache": false, + "Challenges": [ + { + "Mechanisms": [ + { + "AnswerType": "Text", + "Name": "UP", + "PromptMechChosen": "Enter Password", + "PromptSelectMech": "Password", + "MechanismId": "aaaaaaa_AAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111", + "Enrolled": true + } + ] + } + ], + "Summary": "NewPackage", + "TenantId": "TENANTID" + }, + "Message": null, + "MessageID": null, + "Exception": null, + "ErrorID": null, + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_challenges.json b/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_challenges.json new file mode 100644 index 00000000..eb075a6e --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_challenges.json @@ -0,0 +1,53 @@ +{ + "success": true, + "Result": { + "ClientHints": { + "PersistDefault": false, + "AllowPersist": true, + "AllowForgotPassword": true, + "EndpointAuthenticationEnabled": false + }, + "Version": "1.0", + "SessionId": "mysessionid101", + "EventDescription": null, + "RetryWaitingTime": 0, + "SecurityImageName": null, + "AllowLoginMfaCache": false, + "Challenges": [ + { + "Mechanisms": [ + { + "AnswerType": "StartOob", + "Name": "PF", + "PartialPhoneNumber": "0775", + "PromptMechChosen": "We will now attempt to call your phone (0000). Please follow the instructions to proceed with authentication.", + "PromptSelectMech": "Phone Call... XXX-0000", + "MechanismId": "bbbbbbb_BBBBBBBBBBBBBBBBBBBBBBBBBBBB-2222222", + "Enrolled": true + } + ] + }, + { + "Mechanisms": [ + { + "AnswerType": "Text", + "Name": "UP", + "PromptMechChosen": "Enter Password", + "PromptSelectMech": "Password", + "MechanismId": "aaaaaaa_AAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111", + "Enrolled": true + } + ] + } + ], + "Summary": "NewPackage", + "TenantId": "TENANTID" + }, + "Message": null, + "MessageID": null, + "Exception": null, + "ErrorID": null, + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_mechanisms.json b/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_mechanisms.json new file mode 100644 index 00000000..b66adc41 --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/start_authentication_success_multiple_mechanisms.json @@ -0,0 +1,49 @@ +{ + "success": true, + "Result": { + "ClientHints": { + "PersistDefault": false, + "AllowPersist": true, + "AllowForgotPassword": true, + "EndpointAuthenticationEnabled": false + }, + "Version": "1.0", + "SessionId": "mysessionid101", + "EventDescription": null, + "RetryWaitingTime": 0, + "SecurityImageName": null, + "AllowLoginMfaCache": false, + "Challenges": [ + { + "Mechanisms": [ + { + "AnswerType": "Text", + "Name": "UP", + "PromptMechChosen": "Enter Password", + "PromptSelectMech": "Password", + "MechanismId": "aaaaaaa_AAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111", + "Enrolled": true + }, + { + "AnswerType": "StartOob", + "Name": "PF", + "PartialPhoneNumber": "0775", + "PromptMechChosen": "We will now attempt to call your phone (0000). Please follow the instructions to proceed with authentication.", + "PromptSelectMech": "Phone Call... XXX-0000", + "MechanismId": "bbbbbbb_BBBBBBBBBBBBBBBBBBBBBBBBBBBB-2222222", + "Enrolled": true + } + ] + } + ], + "Summary": "NewPackage", + "TenantId": "TENANTID" + }, + "Message": null, + "MessageID": null, + "Exception": null, + "ErrorID": null, + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/identity/testdata/start_authentication_success_no_up_mechanism.json b/pkg/internal/cyberark/identity/testdata/start_authentication_success_no_up_mechanism.json new file mode 100644 index 00000000..f6828686 --- /dev/null +++ b/pkg/internal/cyberark/identity/testdata/start_authentication_success_no_up_mechanism.json @@ -0,0 +1,41 @@ +{ + "success": true, + "Result": { + "ClientHints": { + "PersistDefault": false, + "AllowPersist": true, + "AllowForgotPassword": true, + "EndpointAuthenticationEnabled": false + }, + "Version": "1.0", + "SessionId": "mysessionid101", + "EventDescription": null, + "RetryWaitingTime": 0, + "SecurityImageName": null, + "AllowLoginMfaCache": false, + "Challenges": [ + { + "Mechanisms": [ + { + "AnswerType": "StartOob", + "Name": "PF", + "PartialPhoneNumber": "0775", + "PromptMechChosen": "We will now attempt to call your phone (0000). Please follow the instructions to proceed with authentication.", + "PromptSelectMech": "Phone Call... XXX-0000", + "MechanismId": "bbbbbbb_BBBBBBBBBBBBBBBBBBBBBBBBBBBB-2222222", + "Enrolled": true + } + ] + } + ], + "Summary": "NewPackage", + "TenantId": "TENANTID" + }, + "Message": null, + "MessageID": null, + "Exception": null, + "ErrorID": null, + "ErrorCode": null, + "IsSoftError": false, + "InnerExceptions": null +} diff --git a/pkg/internal/cyberark/servicediscovery/discovery_test.go b/pkg/internal/cyberark/servicediscovery/discovery_test.go index 6699a5f8..8dd19ed6 100644 --- a/pkg/internal/cyberark/servicediscovery/discovery_test.go +++ b/pkg/internal/cyberark/servicediscovery/discovery_test.go @@ -2,93 +2,10 @@ package servicediscovery import ( "context" - "crypto/rand" - "encoding/hex" - "encoding/json" "fmt" - "net/http" - "net/http/httptest" - "strings" "testing" - - "github.com/jetstack/preflight/pkg/version" - - _ "embed" ) -//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 @@ -96,7 +13,7 @@ func Test_DiscoverIdentityAPIURL(t *testing.T) { expectedError error }{ "successful request": { - subdomain: "venafi-test", + subdomain: MockDiscoverySubdomain, expectedURL: "https://ajp5871.id.integration-cyberark.cloud", expectedError: nil, }, @@ -131,11 +48,10 @@ func Test_DiscoverIdentityAPIURL(t *testing.T) { t.Run(name, func(t *testing.T) { ctx := context.Background() - ts := httptest.NewServer(http.HandlerFunc(testHandler)) - + ts := MockDiscoveryServer() defer ts.Close() - client := New(WithCustomEndpoint(ts.URL)) + client := New(WithCustomEndpoint(ts.Server.URL)) apiURL, err := client.DiscoverIdentityAPIURL(ctx, testSpec.subdomain) if err != nil { diff --git a/pkg/internal/cyberark/servicediscovery/mock.go b/pkg/internal/cyberark/servicediscovery/mock.go new file mode 100644 index 00000000..928d8fc7 --- /dev/null +++ b/pkg/internal/cyberark/servicediscovery/mock.go @@ -0,0 +1,133 @@ +package servicediscovery + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "text/template" + + "github.com/jetstack/preflight/pkg/version" + + _ "embed" +) + +const ( + // MockDiscoverySubdomain is the subdomain for which the MockDiscoveryServer will return a success response + MockDiscoverySubdomain = "venafi-test" + + defaultIdentityAPIURL = "https://ajp5871.id.integration-cyberark.cloud" +) + +//go:embed testdata/discovery_success.json.template +var discoverySuccessTemplate string + +type mockDiscoveryServer struct { + Server *httptest.Server + + successResponse string +} + +// MockDiscoveryServer returns a mocked discovery server with a default value for the Identity API. +// The returned server should be Closed by the caller after use. +func MockDiscoveryServer() *mockDiscoveryServer { + return MockDiscoveryServerWithCustomAPIURL(defaultIdentityAPIURL) +} + +func MockDiscoveryServerWithCustomAPIURL(apiURL string) *mockDiscoveryServer { + tmpl := template.Must(template.New("mockDiscoverySuccess").Parse(discoverySuccessTemplate)) + + buf := &bytes.Buffer{} + + err := tmpl.Execute(buf, struct{ IdentityAPIURL string }{apiURL}) + if err != nil { + panic(err) + } + + mds := &mockDiscoveryServer{ + successResponse: buf.String(), + } + + server := httptest.NewServer(mds) + + mds.Server = server + + return mds +} + +func (mds *mockDiscoveryServer) Close() { + mds.Server.Close() +} + +func (mds *mockDiscoveryServer) ServeHTTP(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 MockDiscoverySubdomain: + _, _ = w.Write([]byte(mds.successResponse)) + + 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("{}")) + } +} diff --git a/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json b/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json.template similarity index 55% rename from pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json rename to pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json.template index 81d69668..016a1107 100644 --- a/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json +++ b/pkg/internal/cyberark/servicediscovery/testdata/discovery_success.json.template @@ -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"}} \ No newline at end of file +{"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": "{{ .IdentityAPIURL }}", "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"}}