Skip to content
Open
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
109 changes: 109 additions & 0 deletions internal/cyberark/auth_select_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cyberark_test

import (
"net/http"
"os"
"testing"

"github.com/stretchr/testify/require"
"k8s.io/klog/v2"
"k8s.io/klog/v2/ktesting"

"github.com/jetstack/preflight/internal/cyberark"
"github.com/jetstack/preflight/internal/cyberark/conjur"
"github.com/jetstack/preflight/internal/cyberark/dataupload"
"github.com/jetstack/preflight/internal/cyberark/identity"
"github.com/jetstack/preflight/internal/cyberark/servicediscovery"

_ "k8s.io/klog/v2/ktesting/init"
)

// The agent supports two coexisting auth methods (the product is GA). These
// tests pin the selection rule in NewDatauploadClient / selectAuthenticator:
// - ServiceID set → Conjur JWT exchange
// - else Username+Secret present → legacy username/password
// - both set → Conjur wins
// - neither → ErrNoAuthMethod
func TestNewDatauploadClient_AuthMethodSelection(t *testing.T) {
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
ctx := klog.NewContext(t.Context(), logger)

const conjurToken = "success-token" // matches dataupload mock's expected bearer token

writeJWT := func(t *testing.T) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "jwt-*")
require.NoError(t, err)
_, err = f.WriteString("fake-service-account-jwt")
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}

// stack builds a service map whose DiscoveryContext points at a dataupload
// mock (which requires Authorization: Bearer success-token). The Identity
// and SecretsManager endpoints are supplied separately and deliberately
// differ: the username/password path must use Identity and the Conjur
// authn-jwt exchange must use SecretsManager, so pointing a mock at only
// one of them proves which endpoint the code actually called.
stack := func(t *testing.T, identityAPI, smsAPI string) *servicediscovery.Services {
t.Helper()
discoveryContextAPI, _ := dataupload.MockDataUploadServer(t)
return &servicediscovery.Services{
Identity: servicediscovery.ServiceEndpoint{API: identityAPI},
DiscoveryContext: servicediscovery.ServiceEndpoint{API: discoveryContextAPI},
SecretsManager: servicediscovery.ServiceEndpoint{API: smsAPI},
}
}

// Endpoints that must never be dialled by the path under test.
const unusedIdentity = "https://identity.example.invalid"
const unusedSMS = "https://secretsmgr.example.invalid"

t.Run("serviceID set -> conjur path", func(t *testing.T) {
conjurSrv, _ := conjur.MockConjurExchangeServer(t, conjurToken)
t.Cleanup(conjurSrv.Close)

cfg := cyberark.ClientConfig{
ServiceID: "dev-cluster",
JWTFilePath: writeJWT(t),
}
_, err := cyberark.NewDatauploadClient(ctx, conjurSrv.Client(), stack(t, unusedIdentity, conjurSrv.URL), "tenant", cfg)
require.NoError(t, err)
})

t.Run("username/password only -> identity path", func(t *testing.T) {
identityURL, httpClient := identity.MockIdentityServer(t)

cfg := cyberark.ClientConfig{
Subdomain: "tenant-sub",
Username: identity.MockSuccessUser,
Secret: []byte(identity.MockSuccessPassword),
}
// Login happens during construction; success proves the UP path ran.
_, err := cyberark.NewDatauploadClient(ctx, httpClient, stack(t, identityURL, unusedSMS), "tenant", cfg)
require.NoError(t, err)
})

t.Run("both set -> conjur wins", func(t *testing.T) {
conjurSrv, _ := conjur.MockConjurExchangeServer(t, conjurToken)
t.Cleanup(conjurSrv.Close)

cfg := cyberark.ClientConfig{
ServiceID: "dev-cluster",
JWTFilePath: writeJWT(t),
// UP creds present too — must be ignored. Deliberately bogus so that
// if the identity path were taken, login would fail.
Username: "should-not-be-used@example.com",
Secret: []byte("wrong-password"),
}
_, err := cyberark.NewDatauploadClient(ctx, conjurSrv.Client(), stack(t, unusedIdentity, conjurSrv.URL), "tenant", cfg)
require.NoError(t, err) // conjur path used; bogus UP creds never exercised
})

t.Run("neither set -> ErrNoAuthMethod", func(t *testing.T) {
cfg := cyberark.ClientConfig{Subdomain: "tenant-sub"}
_, err := cyberark.NewDatauploadClient(ctx, &http.Client{}, stack(t, unusedIdentity, unusedSMS), "tenant", cfg)
require.ErrorIs(t, err, cyberark.ErrNoAuthMethod)
})
}
158 changes: 130 additions & 28 deletions internal/cyberark/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,69 +3,171 @@ package cyberark
import (
"context"
"errors"
"fmt"
"net/http"
"os"

"k8s.io/klog/v2"

"github.com/jetstack/preflight/internal/cyberark/conjur"
"github.com/jetstack/preflight/internal/cyberark/dataupload"
"github.com/jetstack/preflight/internal/cyberark/identity"
"github.com/jetstack/preflight/internal/cyberark/jwtsource"
"github.com/jetstack/preflight/internal/cyberark/servicediscovery"
)

const (
// JWTSourceFile is the only currently-supported JWTSource value (besides
// the empty string, which also means "file").
JWTSourceFile = "file"

// DefaultAccount is the Conjur account name used when ClientConfig.Account
// is unset.
DefaultAccount = "conjur"
)

// ValidateJWTSource returns an error if source is set to something other than
// the empty string or JWTSourceFile — the only supported jwt_source values.
// Shared so the CLI/config-file validation path and the client construction
// path can't drift on what's accepted.
func ValidateJWTSource(source string) error {
if source != "" && source != JWTSourceFile {
return fmt.Errorf("%q is not supported; supported values are \"\" and %q", source, JWTSourceFile)
}
return nil
}

// ClientConfig holds the configuration needed to initialize a CyberArk client.
//
// Two authentication methods coexist (the product is GA; existing installs use
// username/password). The active method is selected by config presence, see
// selectAuthenticator: a Conjur authn-jwt ServiceID, when set, takes precedence
// over username/password.
type ClientConfig struct {
Subdomain string
Username string
Secret string

// Conjur JWT exchange (preferred for new installs).
ServiceID string // authn-jwt service id (POC: per-cluster, e.g. "dev-cluster")
Account string // defaults to DefaultAccount
JWTSource string // "" or JWTSourceFile (POC) | "spiffe" (deferred)
JWTFilePath string // default jwtsource.DefaultTokenPath

// Legacy CyberArk Identity username/password (backward compatibility).
// Sourced from ARK_USERNAME / ARK_SECRET. Used only when ServiceID is unset.
Username string
Secret []byte

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This type change from string to []byte is the root cause of the blocker below. Previously the call site was []byte(cfg.Secret), which allocated a fresh copy on every call — that's what made the zeroing safe. Now the same array is shared between the config and the callee that wipes it.

Beyond the one-line fix, I'd reconsider the contract: two owners of one array with an implicit "callee wipes it" rule is what caused this. Cleaner is for LoginUsernamePassword to copy internally and stop zeroing the caller's slice — the client now deliberately retains its own copy for the process lifetime anyway (identity.go's secret field), so wiping the caller's buys nothing and only leaves this hazard for the next caller.

}

// ClientConfigLoader is a function type that loads and returns a ClientConfig.
type ClientConfigLoader func() (ClientConfig, error)

// ErrMissingEnvironmentVariables is returned when required environment variables are not set.
var ErrMissingEnvironmentVariables = errors.New("missing environment variables: ARK_SUBDOMAIN, ARK_USERNAME, ARK_SECRET")
var ErrMissingEnvironmentVariables = errors.New("missing environment variables: ARK_SUBDOMAIN")

// ErrNoAuthMethod is returned when neither a Conjur service-id nor
// username/password credentials are configured.
var ErrNoAuthMethod = errors.New("no CyberArk authentication method configured: set config.cyberark.service_id (Conjur JWT) or ARK_USERNAME + ARK_SECRET (legacy username/password)")

// LoadClientConfigFromEnvironment loads the CyberArk client configuration from environment variables.
// It expects the following environment variables to be set:
// - ARK_SUBDOMAIN: The CyberArk subdomain to use.
// - ARK_USERNAME: The username for authentication.
// - ARK_SECRET: The secret for authentication.
// It expects the following environment variable to be set:
// - ARK_SUBDOMAIN: The CyberArk subdomain to use (required).
//
// It also reads the optional legacy username/password credentials:
// - ARK_USERNAME, ARK_SECRET: used only when no Conjur service-id is configured.
//
// Behavioral keys (ServiceID, Account, JWTSource, JWTFilePath) are set by the
// caller from the agent YAML config (config.cyberark.*).
func LoadClientConfigFromEnvironment() (ClientConfig, error) {
subdomain := os.Getenv("ARK_SUBDOMAIN")
username := os.Getenv("ARK_USERNAME")
secret := os.Getenv("ARK_SECRET")

if subdomain == "" || username == "" || secret == "" {
if subdomain == "" {
return ClientConfig{}, ErrMissingEnvironmentVariables
}

return ClientConfig{
cfg := ClientConfig{
Subdomain: subdomain,
Username: username,
Secret: secret,
}, nil
Username: os.Getenv("ARK_USERNAME"),
}
if secret := os.Getenv("ARK_SECRET"); secret != "" {
cfg.Secret = []byte(secret)
}
return cfg, nil
}

// selectAuthenticator builds the request authenticator for the configured auth
// method and returns it together with the discovery-context API endpoint.
//
// Selection (backward compatible — the product is GA):
// - ServiceID set → Conjur JWT exchange (preferred).
// - else Username+Secret present → legacy CyberArk Identity UP login.
// - neither → ErrNoAuthMethod.
//
// When both are configured, ServiceID wins (a migrating install can set the
// service-id without first removing its old credentials) and a warning is logged.
func selectAuthenticator(ctx context.Context, httpClient *http.Client, serviceMap *servicediscovery.Services, cfg ClientConfig) (identity.RequestAuthenticator, error) {
hasConjur := cfg.ServiceID != ""
hasUP := cfg.Username != "" && len(cfg.Secret) > 0

switch {
case hasConjur:
if hasUP {
klog.FromContext(ctx).Info("both Conjur service_id and ARK_USERNAME/ARK_SECRET are set; using the Conjur JWT exchange and ignoring the username/password credentials")
}
if err := ValidateJWTSource(cfg.JWTSource); err != nil {
return nil, fmt.Errorf("jwt_source %w", err)
}
account := cfg.Account
if account == "" {
account = DefaultAccount
}
// The authn-jwt exchange is served by Secrets Manager (Conjur Cloud),
// not by identity_administration — those are different hosts. Tenant
// onboarding registers the authenticator on the Secrets Manager host,
// and the server that later validates the resulting token resolves the
// same service from service discovery.
smsAPI := serviceMap.SecretsManager.API
if smsAPI == "" {
return nil, errors.New("service discovery returned an empty secrets_manager API, which is required for the Conjur JWT exchange")
}
src := jwtsource.NewFileSource(cfg.JWTFilePath)
conjurClient := conjur.New(httpClient, smsAPI, cfg.ServiceID, account, src)
return conjurClient.AuthenticateRequest, nil

case hasUP:
identityAPI := serviceMap.Identity.API
if identityAPI == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The nit I flagged on #820 at discovery.go:198, raised here where it belongs.

This check can't fire. DiscoverServices returns an error when identity_administration is absent (servicediscovery/discovery.go:198), and every caller — NewDatauploadClient, keyfetch.NewClient, keyfetch.FetchKey — runs discovery before reaching here, so serviceMap.Identity.API is guaranteed non-empty by the time selectAuthenticator runs.

Moving it into hasUP was the right response to my round-1 point; the issue is what it now implies. It reads as "the Conjur path tolerates a missing Identity service," and that isn't a property the system has — a JWT-only tenant without identity_administration is rejected by discovery long before this branch. Suggest dropping it:

case hasUP:
    identityClient := identity.New(httpClient, serviceMap.Identity.API, cfg.Subdomain)

The smsAPI check above stays — that one is genuinely reachable, and it's exactly the caller-validates pattern #820's new comment describes.

To be clear I'm not asking to relax discovery. Every service in testdata/discovery_success.json.template is present and active for the captured tenant, identity_administration included, so requiring it looks correct — and it may be the only suspended-tenant signal available, since tenant_flags carries nothing about suspension. The requirement is fine; the unreachable second check is what I'd remove.

Two optional follow-ons if you agree, both one-liners:

  • a note above discovery.go:198 saying identityAPI is required unconditionally because it's present for every healthy tenant, so callers may rely on it being non-empty — the block at :202 explains why the other two aren't required but says nothing about this one
  • pkg/testutil/envtest.go:291 currently reads "Unused by the Conjur path, but service discovery requires it," which sounds like a workaround; with the contract written down it can just cite it

return nil, errors.New("service discovery returned an empty identity API")
}
identityClient := identity.New(httpClient, identityAPI, cfg.Subdomain)
if err := identityClient.LoginUsernamePassword(ctx, cfg.Username, cfg.Secret); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker. LoginUsernamePassword zeroes the caller's slice on return. cfg is passed by value, but Secret is a slice — the copy shares one backing array — so this wipes the caller's secret in place.

NewCyberArk builds one cfg and hands out a closure returning that same value forever (pkg/client/client_cyberark.go:55), and both consumers go through it:

  • loadEncryptorkeyfetch.NewClientNewRequestAuthenticator (run.go:175, startup)
  • PostDataReadingsWithOptionsNewDatauploadClient (run.go:272, every config.period)

So on the chart default sendSecretValues: true, startup consumes the secret and the first upload already fails. With it false, the first upload works and every one after fails. Permanent either way — ARK_SECRET is only read at startup.

hasUP doesn't notice: len(cfg.Secret) > 0 is still true for an all-zeros slice, so we take this branch and send zeros.

Reproduced through the real client path:

--- FAIL: TestProbe_UsernamePassword_SecondUpload
    Error: while initializing data upload client: CyberArk Identity username/password
           login failed: got a failure response from request to advance authentication:
           message="Authentication (login or challenge) has failed..."
    Messages: second upload

and at this level directly — cfg.Secret after first use: "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".

Verified fix, one line:

if err := identityClient.LoginUsernamePassword(ctx, cfg.Username, append([]byte(nil), cfg.Secret...)); err != nil {

CI is green only because every test builds a fresh config while the agent reuses one — the closest test uploads exactly once. I've left the concrete regression-test request on #823, where that test lives.

return nil, fmt.Errorf("CyberArk Identity username/password login failed: %w", err)
}
return identityClient.AuthenticateRequest, nil

default:
return nil, ErrNoAuthMethod
}
}

// NewRequestAuthenticator selects and builds the configured request
// authenticator (Conjur JWT exchange or legacy username/password). Exposed for
// other consumers (e.g. envelope key fetching) that need the same auth seam
// without a dataupload client.
func NewRequestAuthenticator(ctx context.Context, httpClient *http.Client, serviceMap *servicediscovery.Services, cfg ClientConfig) (identity.RequestAuthenticator, error) {
return selectAuthenticator(ctx, httpClient, serviceMap, cfg)
}

// NewDatauploadClient initializes and returns a new CyberArk Data Upload client.
// It performs service discovery to find the necessary API endpoints and authenticates
// using the provided client configuration.
// It performs service discovery to find the necessary API endpoints and
// authenticates using whichever method is configured (Conjur JWT exchange or
// legacy username/password — see selectAuthenticator).
func NewDatauploadClient(ctx context.Context, httpClient *http.Client, serviceMap *servicediscovery.Services, tenantUUID string, cfg ClientConfig) (*dataupload.CyberArkClient, error) {
identityAPI := serviceMap.Identity.API
if identityAPI == "" {
return nil, errors.New("service discovery returned an empty identity API")
}

discoveryAPI := serviceMap.DiscoveryContext.API
if discoveryAPI == "" {
return nil, errors.New("service discovery returned an empty discovery API")
}

identityClient := identity.New(httpClient, identityAPI, cfg.Subdomain)

err := identityClient.LoginUsernamePassword(ctx, cfg.Username, []byte(cfg.Secret))
authenticate, err := selectAuthenticator(ctx, httpClient, serviceMap, cfg)
if err != nil {
return nil, err
}

return dataupload.New(httpClient, discoveryAPI, tenantUUID, identityClient.AuthenticateRequest), nil
return dataupload.New(httpClient, discoveryAPI, tenantUUID, authenticate), nil
}
Loading