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
416 changes: 3 additions & 413 deletions internal/cyberark/identity/identity.go

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion internal/cyberark/identity/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ const (
// mock server in response to a successful AdvanceAuthentication request
// Must match what's in testdata/advance_authentication_success.json
mockSuccessfulStartAuthenticationToken = "success-token"

// actionAnswer is the string sent to an AdvanceAuthentication request to indicate we're
// providing credentials as plain text.
actionAnswer = "Answer"
)

// Exported credentials that MockIdentityServer accepts as a successful
// username/password login. Used by other packages' tests that exercise the
// legacy UP auth path against the mock server.
const (
MockSuccessUser = successUser
MockSuccessPassword = successPassword
)

var (
Expand Down Expand Up @@ -213,7 +225,7 @@ func (mis *mockIdentityServer) handleAdvanceAuthentication(w http.ResponseWriter

if advanceBody.SessionID != successSessionID ||
advanceBody.MechanismID != successMechanismID ||
advanceBody.Action != ActionAnswer ||
advanceBody.Action != actionAnswer ||
advanceBody.Answer != successPassword {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(advanceAuthenticationFailureResponse))
Expand Down
424 changes: 424 additions & 0 deletions internal/cyberark/identity/username_password.go

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions internal/cyberark/jwtsource/jwtsource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// internal/cyberark/jwtsource/jwtsource.go
package jwtsource

import (
"context"
"fmt"
"os"
"strings"
)

// DefaultTokenPath is the default projected ServiceAccount token mount (aud=conjur).
const DefaultTokenPath = "/var/run/secrets/tokens/jwt"

// Source produces a raw JWT to exchange at SMS authn-jwt.
type Source interface {
Read(ctx context.Context) (string, error)
}

type fileSource struct{ path string }

// NewFileSource reads a JWT from a file (the projected SA token).
func NewFileSource(path string) Source {
if path == "" {
path = DefaultTokenPath
}
return &fileSource{path: path}
}

func (f *fileSource) Read(_ context.Context) (string, error) {
b, err := os.ReadFile(f.path)
if err != nil {
return "", fmt.Errorf("jwt source file %q not found or unreadable (is the projected serviceAccountToken volume mounted?): %w", f.path, err)
}
tok := strings.TrimSpace(string(b))
if tok == "" {
return "", fmt.Errorf("jwt source file %q is empty", f.path)
}
return tok, nil
}
33 changes: 33 additions & 0 deletions internal/cyberark/jwtsource/jwtsource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// internal/cyberark/jwtsource/jwtsource_test.go
package jwtsource

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestFileSource_ReadsToken(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "jwt")
require.NoError(t, os.WriteFile(p, []byte("the-jwt\n"), 0o600))
got, err := NewFileSource(p).Read(context.Background())
require.NoError(t, err)
require.Equal(t, "the-jwt", got) // trimmed
}

func TestFileSource_MissingFile(t *testing.T) {
_, err := NewFileSource("/no/such/file").Read(context.Background())
require.Error(t, err)
}

func TestFileSource_EmptyFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "jwt")
require.NoError(t, os.WriteFile(p, []byte(" \n"), 0o600))
_, err := NewFileSource(p).Read(context.Background())
require.Error(t, err)
}
23 changes: 20 additions & 3 deletions internal/cyberark/servicediscovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ const (
// in responses from the Service Discovery API.
DiscoveryContextServiceName = "discoverycontext"

// SecretsManagerServiceName is the name of the Secrets Manager (Conjur
// Cloud) API in responses from the Service Discovery API. This is the host
// that serves `authn-jwt/<service-id>/<account>/authenticate` — NOT the
// identity_administration host. The server that validates the resulting
// token resolves this same service name.
SecretsManagerServiceName = "secrets_manager"

// maxDiscoverBodySize is the maximum allowed size for a response body from the CyberArk Service Discovery subdomain endpoint
// As of 2025-04-16, a response from the integration environment is ~4kB
maxDiscoverBodySize = 2 * 1024 * 1024
Expand Down Expand Up @@ -101,11 +108,13 @@ type ServiceEndpoint struct {
API string `json:"api"`
}

// This is a convenience struct to hold the two ServiceEndpoints we care about.
// Currently, we only care about the Identity API and the Discovery Context API.
// This is a convenience struct to hold the ServiceEndpoints we care about:
// the Identity API, the Discovery Context API, and the Secrets Manager
// (Conjur Cloud) API used for the authn-jwt token exchange.
type Services struct {
Identity ServiceEndpoint
DiscoveryContext ServiceEndpoint
SecretsManager ServiceEndpoint
}

// DiscoverServices fetches from the service discovery service for the configured subdomain
Expand Down Expand Up @@ -163,7 +172,7 @@ func (c *Client) DiscoverServices(ctx context.Context) (*Services, string, error
}
return nil, "", fmt.Errorf("failed to parse JSON from otherwise successful request to service discovery endpoint: %s", err)
}
var identityAPI, discoveryContextAPI string
var identityAPI, discoveryContextAPI, secretsManagerAPI string
for _, svc := range discoveryResp.Services {
switch svc.ServiceName {
case IdentityServiceName:
Expand All @@ -180,6 +189,13 @@ func (c *Client) DiscoverServices(ctx context.Context) (*Services, string, error
break
}
}
case SecretsManagerServiceName:
for _, ep := range svc.Endpoints {
if ep.Type == "main" && ep.IsActive && ep.API != "" {
secretsManagerAPI = ep.API
break
}
}
}
}

Expand All @@ -192,6 +208,7 @@ func (c *Client) DiscoverServices(ctx context.Context) (*Services, string, error
services := &Services{
Identity: ServiceEndpoint{API: identityAPI},
DiscoveryContext: ServiceEndpoint{API: discoveryContextAPI},
SecretsManager: ServiceEndpoint{API: secretsManagerAPI},
}

c.cachedResponse = services
Expand Down
10 changes: 10 additions & 0 deletions internal/cyberark/servicediscovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func Test_DiscoverIdentityAPIURL(t *testing.T) {
DiscoveryContext: ServiceEndpoint{
API: mockDiscoveryContextAPIURL,
},
SecretsManager: ServiceEndpoint{
API: mockSecretsManagerAPIURL,
},
})

client := New(httpClient, testSpec.subdomain)
Expand All @@ -76,6 +79,13 @@ func Test_DiscoverIdentityAPIURL(t *testing.T) {
if services.Identity.API != testSpec.expectedURL {
t.Errorf("expected API URL=%s\nobserved API URL=%s", testSpec.expectedURL, services.Identity.API)
}
// The Conjur authn-jwt exchange is served by secrets_manager, not
// by identity_administration. Parsing it into the wrong field means
// every live token exchange 404s/401s, which the Conjur unit tests
// cannot catch because they point their mock at whichever field the
// code reads.
assert.Equal(t, mockSecretsManagerAPIURL, services.SecretsManager.API)
assert.NotEqual(t, services.Identity.API, services.SecretsManager.API)
})
}
}
1 change: 1 addition & 0 deletions internal/cyberark/servicediscovery/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (

mockIdentityAPIURL = "https://ajp5871.id.integration-cyberark.cloud"
mockDiscoveryContextAPIURL = "https://venafi-test.inventory.integration-cyberark.cloud/"
mockSecretsManagerAPIURL = "https://venafi-test.secretsmgr.integration-cyberark.cloud/api"
prefix = "/api/public/tenant-discovery?bySubdomain="
)

Expand Down
5 changes: 3 additions & 2 deletions internal/cyberark/servicediscovery/testdata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ NOTE: This API is not implemented yet as of 02.09.2025 but is expected to be fin
curl -fsSL "${ARK_DISCOVERY_API}?bySubdomain=${ARK_SUBDOMAIN}" | jq
```

Then replace `identity_administration.api` with `{{ .Identity.API }}` and
`discoverycontext.api` with `{{ .DiscoveryContext.API }}`. Those Go template
Then replace `identity_administration.api` with `{{ .Identity.API }}`,
`discoverycontext.api` with `{{ .DiscoveryContext.API }}`, and
`secrets_manager.api` with `{{ .SecretsManager.API }}`. Those Go template
fields will be substituted in the tests.
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"is_active": true,
"type": "main",
"ui": "https://ui.test-conjur.cloud",
"api": "https://venafi-test.secretsmgr.integration-cyberark.cloud/api"
"api": "{{ .SecretsManager.API }}"
}
]
},
Expand Down