Skip to content

Commit afbf35e

Browse files
author
rzisholz
committed
CP-21164: select Conjur JWT or legacy username/password authenticator
Adds NewRequestAuthenticator, choosing between the new Conjur JWT exchange and the legacy CyberArk Identity username/password login based on which config is present — Conjur JWT takes priority when both are set. Conjur JWT requires service_id and resolves its base URL from the secrets_manager service discovered in the prior PR, not identity_administration; the two are different hosts. The identity API is only required on the username/password path now; Conjur JWT never uses it. Since service discovery already errors on a missing identity_administration API before selectAuthenticator ever runs, the username/password branch no longer re-checks it — that check could never actually fire. Switches keyfetch's client over to the new authenticator selection instead of constructing a username/password identity client directly. Doing so dropped keyfetch's own per-fetch LoginUsernamePassword call, which was the only thing keeping the cached identity token from aging out — identity.Client.AuthenticateRequest never refreshed on its own. Give it the same self-refreshing behavior conjur.Client already has: it now re-logs-in internally once its cached token passes tokenTTL (a field, not a const, so tests can shrink it), using a durable copy of the credentials it captured at the last LoginUsernamePassword call. A refresh that fails falls back to whatever's cached rather than failing the request outright, since the next call will retry. LoginUsernamePassword no longer zeroes the caller's password slice: it already keeps its own durable copy (needed for the self-refresh above), so wiping the caller's copy bought nothing and actively broke callers that reuse one ClientConfig across multiple logins — cfg.Secret is a []byte shared across every upload cycle via NewCyberArk's configLoader closure, so the second call received an already-zeroed password and failed to authenticate. Reproduced live before fixing: a second PostDataReadingsWithOptions call on the username/password path failed with "Authentication ... has failed" every time. Hoists the jwt_source validation and the "file"/"conjur" literals this and the agent config layer both encode separately into shared JWTSourceFile/DefaultAccount consts and a ValidateJWTSource helper, and drops the "POC" wording from the operator-facing error.
1 parent 23492e8 commit afbf35e

11 files changed

Lines changed: 607 additions & 95 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package cyberark_test
2+
3+
import (
4+
"net/http"
5+
"os"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
"k8s.io/klog/v2"
10+
"k8s.io/klog/v2/ktesting"
11+
12+
"github.com/jetstack/preflight/internal/cyberark"
13+
"github.com/jetstack/preflight/internal/cyberark/conjur"
14+
"github.com/jetstack/preflight/internal/cyberark/dataupload"
15+
"github.com/jetstack/preflight/internal/cyberark/identity"
16+
"github.com/jetstack/preflight/internal/cyberark/servicediscovery"
17+
18+
_ "k8s.io/klog/v2/ktesting/init"
19+
)
20+
21+
// The agent supports two coexisting auth methods (the product is GA). These
22+
// tests pin the selection rule in NewDatauploadClient / selectAuthenticator:
23+
// - ServiceID set → Conjur JWT exchange
24+
// - else Username+Secret present → legacy username/password
25+
// - both set → Conjur wins
26+
// - neither → ErrNoAuthMethod
27+
func TestNewDatauploadClient_AuthMethodSelection(t *testing.T) {
28+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
29+
ctx := klog.NewContext(t.Context(), logger)
30+
31+
const conjurToken = "success-token" // matches dataupload mock's expected bearer token
32+
33+
writeJWT := func(t *testing.T) string {
34+
t.Helper()
35+
f, err := os.CreateTemp(t.TempDir(), "jwt-*")
36+
require.NoError(t, err)
37+
_, err = f.WriteString("fake-service-account-jwt")
38+
require.NoError(t, err)
39+
require.NoError(t, f.Close())
40+
return f.Name()
41+
}
42+
43+
// stack builds a service map whose DiscoveryContext points at a dataupload
44+
// mock (which requires Authorization: Bearer success-token). The Identity
45+
// and SecretsManager endpoints are supplied separately and deliberately
46+
// differ: the username/password path must use Identity and the Conjur
47+
// authn-jwt exchange must use SecretsManager, so pointing a mock at only
48+
// one of them proves which endpoint the code actually called.
49+
stack := func(t *testing.T, identityAPI, smsAPI string) *servicediscovery.Services {
50+
t.Helper()
51+
discoveryContextAPI, _ := dataupload.MockDataUploadServer(t)
52+
return &servicediscovery.Services{
53+
Identity: servicediscovery.ServiceEndpoint{API: identityAPI},
54+
DiscoveryContext: servicediscovery.ServiceEndpoint{API: discoveryContextAPI},
55+
SecretsManager: servicediscovery.ServiceEndpoint{API: smsAPI},
56+
}
57+
}
58+
59+
// Endpoints that must never be dialled by the path under test.
60+
const unusedIdentity = "https://identity.example.invalid"
61+
const unusedSMS = "https://secretsmgr.example.invalid"
62+
63+
t.Run("serviceID set -> conjur path", func(t *testing.T) {
64+
conjurSrv, _ := conjur.MockConjurExchangeServer(t, conjurToken)
65+
t.Cleanup(conjurSrv.Close)
66+
67+
cfg := cyberark.ClientConfig{
68+
ServiceID: "dev-cluster",
69+
JWTFilePath: writeJWT(t),
70+
}
71+
_, err := cyberark.NewDatauploadClient(ctx, conjurSrv.Client(), stack(t, unusedIdentity, conjurSrv.URL), "tenant", cfg)
72+
require.NoError(t, err)
73+
})
74+
75+
t.Run("username/password only -> identity path", func(t *testing.T) {
76+
identityURL, httpClient := identity.MockIdentityServer(t)
77+
78+
cfg := cyberark.ClientConfig{
79+
Subdomain: "tenant-sub",
80+
Username: identity.MockSuccessUser,
81+
Secret: []byte(identity.MockSuccessPassword),
82+
}
83+
// Login happens during construction; success proves the UP path ran.
84+
_, err := cyberark.NewDatauploadClient(ctx, httpClient, stack(t, identityURL, unusedSMS), "tenant", cfg)
85+
require.NoError(t, err)
86+
})
87+
88+
t.Run("both set -> conjur wins", func(t *testing.T) {
89+
conjurSrv, _ := conjur.MockConjurExchangeServer(t, conjurToken)
90+
t.Cleanup(conjurSrv.Close)
91+
92+
cfg := cyberark.ClientConfig{
93+
ServiceID: "dev-cluster",
94+
JWTFilePath: writeJWT(t),
95+
// UP creds present too — must be ignored. Deliberately bogus so that
96+
// if the identity path were taken, login would fail.
97+
Username: "should-not-be-used@example.com",
98+
Secret: []byte("wrong-password"),
99+
}
100+
_, err := cyberark.NewDatauploadClient(ctx, conjurSrv.Client(), stack(t, unusedIdentity, conjurSrv.URL), "tenant", cfg)
101+
require.NoError(t, err) // conjur path used; bogus UP creds never exercised
102+
})
103+
104+
t.Run("neither set -> ErrNoAuthMethod", func(t *testing.T) {
105+
cfg := cyberark.ClientConfig{Subdomain: "tenant-sub"}
106+
_, err := cyberark.NewDatauploadClient(ctx, &http.Client{}, stack(t, unusedIdentity, unusedSMS), "tenant", cfg)
107+
require.ErrorIs(t, err, cyberark.ErrNoAuthMethod)
108+
})
109+
}

internal/cyberark/client.go

Lines changed: 126 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,69 +3,167 @@ package cyberark
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"net/http"
78
"os"
89

10+
"k8s.io/klog/v2"
11+
12+
"github.com/jetstack/preflight/internal/cyberark/conjur"
913
"github.com/jetstack/preflight/internal/cyberark/dataupload"
1014
"github.com/jetstack/preflight/internal/cyberark/identity"
15+
"github.com/jetstack/preflight/internal/cyberark/jwtsource"
1116
"github.com/jetstack/preflight/internal/cyberark/servicediscovery"
1217
)
1318

19+
const (
20+
// JWTSourceFile is the only currently-supported JWTSource value (besides
21+
// the empty string, which also means "file").
22+
JWTSourceFile = "file"
23+
24+
// DefaultAccount is the Conjur account name used when ClientConfig.Account
25+
// is unset.
26+
DefaultAccount = "conjur"
27+
)
28+
29+
// ValidateJWTSource returns an error if source is set to something other than
30+
// the empty string or JWTSourceFile — the only supported jwt_source values.
31+
// Shared so the CLI/config-file validation path and the client construction
32+
// path can't drift on what's accepted.
33+
func ValidateJWTSource(source string) error {
34+
if source != "" && source != JWTSourceFile {
35+
return fmt.Errorf("%q is not supported; supported values are \"\" and %q", source, JWTSourceFile)
36+
}
37+
return nil
38+
}
39+
1440
// ClientConfig holds the configuration needed to initialize a CyberArk client.
41+
//
42+
// Two authentication methods coexist (the product is GA; existing installs use
43+
// username/password). The active method is selected by config presence, see
44+
// selectAuthenticator: a Conjur authn-jwt ServiceID, when set, takes precedence
45+
// over username/password.
1546
type ClientConfig struct {
1647
Subdomain string
17-
Username string
18-
Secret string
48+
49+
// Conjur JWT exchange (preferred for new installs).
50+
ServiceID string // authn-jwt service id (POC: per-cluster, e.g. "dev-cluster")
51+
Account string // defaults to DefaultAccount
52+
JWTSource string // "" or JWTSourceFile (POC) | "spiffe" (deferred)
53+
JWTFilePath string // default jwtsource.DefaultTokenPath
54+
55+
// Legacy CyberArk Identity username/password (backward compatibility).
56+
// Sourced from ARK_USERNAME / ARK_SECRET. Used only when ServiceID is unset.
57+
Username string
58+
Secret []byte
1959
}
2060

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

2464
// ErrMissingEnvironmentVariables is returned when required environment variables are not set.
25-
var ErrMissingEnvironmentVariables = errors.New("missing environment variables: ARK_SUBDOMAIN, ARK_USERNAME, ARK_SECRET")
65+
var ErrMissingEnvironmentVariables = errors.New("missing environment variables: ARK_SUBDOMAIN")
66+
67+
// ErrNoAuthMethod is returned when neither a Conjur service-id nor
68+
// username/password credentials are configured.
69+
var ErrNoAuthMethod = errors.New("no CyberArk authentication method configured: set config.cyberark.service_id (Conjur JWT) or ARK_USERNAME + ARK_SECRET (legacy username/password)")
2670

2771
// LoadClientConfigFromEnvironment loads the CyberArk client configuration from environment variables.
28-
// It expects the following environment variables to be set:
29-
// - ARK_SUBDOMAIN: The CyberArk subdomain to use.
30-
// - ARK_USERNAME: The username for authentication.
31-
// - ARK_SECRET: The secret for authentication.
72+
// It expects the following environment variable to be set:
73+
// - ARK_SUBDOMAIN: The CyberArk subdomain to use (required).
74+
//
75+
// It also reads the optional legacy username/password credentials:
76+
// - ARK_USERNAME, ARK_SECRET: used only when no Conjur service-id is configured.
77+
//
78+
// Behavioral keys (ServiceID, Account, JWTSource, JWTFilePath) are set by the
79+
// caller from the agent YAML config (config.cyberark.*).
3280
func LoadClientConfigFromEnvironment() (ClientConfig, error) {
3381
subdomain := os.Getenv("ARK_SUBDOMAIN")
34-
username := os.Getenv("ARK_USERNAME")
35-
secret := os.Getenv("ARK_SECRET")
36-
37-
if subdomain == "" || username == "" || secret == "" {
82+
if subdomain == "" {
3883
return ClientConfig{}, ErrMissingEnvironmentVariables
3984
}
40-
41-
return ClientConfig{
85+
cfg := ClientConfig{
4286
Subdomain: subdomain,
43-
Username: username,
44-
Secret: secret,
45-
}, nil
87+
Username: os.Getenv("ARK_USERNAME"),
88+
}
89+
if secret := os.Getenv("ARK_SECRET"); secret != "" {
90+
cfg.Secret = []byte(secret)
91+
}
92+
return cfg, nil
93+
}
94+
95+
// selectAuthenticator builds the request authenticator for the configured auth
96+
// method and returns it together with the discovery-context API endpoint.
97+
//
98+
// Selection (backward compatible — the product is GA):
99+
// - ServiceID set → Conjur JWT exchange (preferred).
100+
// - else Username+Secret present → legacy CyberArk Identity UP login.
101+
// - neither → ErrNoAuthMethod.
102+
//
103+
// When both are configured, ServiceID wins (a migrating install can set the
104+
// service-id without first removing its old credentials) and a warning is logged.
105+
func selectAuthenticator(ctx context.Context, httpClient *http.Client, serviceMap *servicediscovery.Services, cfg ClientConfig) (identity.RequestAuthenticator, error) {
106+
hasConjur := cfg.ServiceID != ""
107+
hasUP := cfg.Username != "" && len(cfg.Secret) > 0
108+
109+
switch {
110+
case hasConjur:
111+
if hasUP {
112+
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")
113+
}
114+
if err := ValidateJWTSource(cfg.JWTSource); err != nil {
115+
return nil, fmt.Errorf("jwt_source %w", err)
116+
}
117+
account := cfg.Account
118+
if account == "" {
119+
account = DefaultAccount
120+
}
121+
// The authn-jwt exchange is served by Secrets Manager (Conjur Cloud),
122+
// not by identity_administration — those are different hosts. Tenant
123+
// onboarding registers the authenticator on the Secrets Manager host,
124+
// and the server that later validates the resulting token resolves the
125+
// same service from service discovery.
126+
smsAPI := serviceMap.SecretsManager.API
127+
if smsAPI == "" {
128+
return nil, errors.New("service discovery returned an empty secrets_manager API, which is required for the Conjur JWT exchange")
129+
}
130+
src := jwtsource.NewFileSource(cfg.JWTFilePath)
131+
conjurClient := conjur.New(httpClient, smsAPI, cfg.ServiceID, account, src)
132+
return conjurClient.AuthenticateRequest, nil
133+
134+
case hasUP:
135+
identityClient := identity.New(httpClient, serviceMap.Identity.API, cfg.Subdomain)
136+
if err := identityClient.LoginUsernamePassword(ctx, cfg.Username, cfg.Secret); err != nil {
137+
return nil, fmt.Errorf("CyberArk Identity username/password login failed: %w", err)
138+
}
139+
return identityClient.AuthenticateRequest, nil
140+
141+
default:
142+
return nil, ErrNoAuthMethod
143+
}
144+
}
46145

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

49154
// NewDatauploadClient initializes and returns a new CyberArk Data Upload client.
50-
// It performs service discovery to find the necessary API endpoints and authenticates
51-
// using the provided client configuration.
155+
// It performs service discovery to find the necessary API endpoints and
156+
// authenticates using whichever method is configured (Conjur JWT exchange or
157+
// legacy username/password — see selectAuthenticator).
52158
func NewDatauploadClient(ctx context.Context, httpClient *http.Client, serviceMap *servicediscovery.Services, tenantUUID string, cfg ClientConfig) (*dataupload.CyberArkClient, error) {
53-
identityAPI := serviceMap.Identity.API
54-
if identityAPI == "" {
55-
return nil, errors.New("service discovery returned an empty identity API")
56-
}
57-
58159
discoveryAPI := serviceMap.DiscoveryContext.API
59160
if discoveryAPI == "" {
60161
return nil, errors.New("service discovery returned an empty discovery API")
61162
}
62163

63-
identityClient := identity.New(httpClient, identityAPI, cfg.Subdomain)
64-
65-
err := identityClient.LoginUsernamePassword(ctx, cfg.Username, []byte(cfg.Secret))
164+
authenticate, err := selectAuthenticator(ctx, httpClient, serviceMap, cfg)
66165
if err != nil {
67166
return nil, err
68167
}
69-
70-
return dataupload.New(httpClient, discoveryAPI, tenantUUID, identityClient.AuthenticateRequest), nil
168+
return dataupload.New(httpClient, discoveryAPI, tenantUUID, authenticate), nil
71169
}

0 commit comments

Comments
 (0)