-
Notifications
You must be signed in to change notification settings - Fork 27
feat(agent): select Conjur JWT or legacy username/password authenticator #822
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
base: master
Are you sure you want to change the base?
Changes from all commits
ffa53d1
33e6b50
0bd93b5
b59e43d
0c06690
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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) | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| } | ||
|
|
||
| // 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 == "" { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The nit I flagged on #820 at This check can't fire. Moving it into case hasUP:
identityClient := identity.New(httpClient, serviceMap.Identity.API, cfg.Subdomain)The To be clear I'm not asking to relax discovery. Every service in Two optional follow-ons if you agree, both one-liners:
|
||
| 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker.
So on the chart default
Reproduced through the real client path: and at this level directly — 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type change from
stringto[]byteis 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
LoginUsernamePasswordto copy internally and stop zeroing the caller's slice — the client now deliberately retains its own copy for the process lifetime anyway (identity.go'ssecretfield), so wiping the caller's buys nothing and only leaves this hazard for the next caller.