From ca4b0131f4550230ea253d5dca9c3cf78655d075 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Sun, 14 Jun 2026 13:29:40 -0700 Subject: [PATCH 01/22] APIGOV-32943 - Extend clients Okta and remove deprecated features. First pass --- pkg/apic/provisioning/idp/provisioner.go | 52 +- pkg/apic/provisioning/idp/provisioner_test.go | 173 +++- pkg/authz/oauth/clients/okta.go | 276 ++++--- pkg/authz/oauth/clients/okta_test.go | 309 ++++++-- pkg/authz/oauth/genericprovider.go | 2 +- pkg/authz/oauth/oktaprovider.go | 311 ++++---- pkg/authz/oauth/oktaprovider_test.go | 748 +++++++++++------- pkg/authz/oauth/provider.go | 71 +- pkg/authz/oauth/provider_test.go | 92 +-- pkg/config/externalidpconfig.go | 107 ++- pkg/config/externalidpconfig_test.go | 282 ++++--- 11 files changed, 1598 insertions(+), 825 deletions(-) diff --git a/pkg/apic/provisioning/idp/provisioner.go b/pkg/apic/provisioning/idp/provisioner.go index 3aedc6d35..a6a9f750d 100644 --- a/pkg/apic/provisioning/idp/provisioner.go +++ b/pkg/apic/provisioning/idp/provisioner.go @@ -2,6 +2,8 @@ package idp import ( "context" + "fmt" + "strings" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/provisioning" @@ -11,7 +13,9 @@ import ( ) const ( - IDPTokenURL = "idpTokenURL" + IDPTokenURL = "idpTokenURL" + registrationClientURIKey = "registrationClientURI" + agentDetailTeamName = "teamName" ) type Provisioner interface { @@ -116,9 +120,13 @@ func (p *provisioner) RegisterClient() error { return nil } - // prepare external client metadata from CRD data + clientName, err := p.appClientName() + if err != nil { + return err + } + builder := oauth.NewClientMetadataBuilder(). - SetClientName(p.credential.GetName()). + SetClientName(clientName). SetScopes(p.credentialData.GetScopes()). SetGrantTypes(p.credentialData.GetGrantTypes()). SetTokenEndpointAuthMethod(p.credentialData.GetTokenEndpointAuthMethod()). @@ -144,7 +152,6 @@ func (p *provisioner) RegisterClient() error { return err } - // provision external client resClientMetadata, err := p.idpProvider.RegisterClient(clientMetadata) if err != nil { return err @@ -155,7 +162,7 @@ func (p *provisioner) RegisterClient() error { p.credentialData.clientSecret = resClientMetadata.GetClientSecret() if resClientMetadata.GetRegistrationClientURI() != "" { - util.SetAgentDetailsKey(p.credential, "registrationClientURI", resClientMetadata.GetRegistrationClientURI()) + util.SetAgentDetailsKey(p.credential, registrationClientURIKey, resClientMetadata.GetRegistrationClientURI()) } return nil @@ -166,9 +173,15 @@ func (p *provisioner) UnregisterClient() error { return nil } - registrationClientURI, _ := util.GetAgentDetailsValue(p.credential, "registrationClientURI") + registrationClientURI, _ := util.GetAgentDetailsValue(p.credential, registrationClientURIKey) + + scopes := p.credentialData.GetScopes() + grantType := "" + if gt := p.credentialData.GetGrantTypes(); len(gt) > 0 { + grantType = gt[0] + } - err := p.idpProvider.UnregisterClient(p.credentialData.GetClientID(), p.credentialData.registrationAccessToken, registrationClientURI) + err := p.idpProvider.UnregisterClient(p.credentialData.GetClientID(), p.credentialData.registrationAccessToken, registrationClientURI, scopes, grantType) if err != nil { return err } @@ -237,3 +250,28 @@ func (p *provisioner) getRegistrationTokenFromAgentDetails() string { registrationToken, _ := util.GetAgentDetailsValue(p.credential, provisioning.OauthRegistrationToken) return registrationToken } + +func (p *provisioner) appClientName() (string, error) { + idpCfg := p.idpProvider.GetConfig() + if idpCfg == nil || idpCfg.GetIDPType() != oauth.TypeOkta { + return p.credential.GetName(), nil + } + oktaCfg, ok := idpCfg.(interface{ GetAppNameTemplate() string }) + if !ok { + return p.credential.GetName(), nil + } + + template := oktaCfg.GetAppNameTemplate() + teamName, _ := util.GetAgentDetailsValue(p.app, agentDetailTeamName) + name := strings.NewReplacer( + config.OktaPlaceholderMPApplicationName, p.app.Name, + config.OktaPlaceholderOwningTeam, teamName, + config.OktaPlaceholderCredentialName, p.credential.GetName(), + ).Replace(template) + + name = util.NormalizeNameForCentral(name) + if len(name) > 100 { + return "", fmt.Errorf("Okta app name exceeds 100-character limit after normalization: %d chars", len(name)) + } + return name, nil +} diff --git a/pkg/apic/provisioning/idp/provisioner_test.go b/pkg/apic/provisioning/idp/provisioner_test.go index e304c6b5a..0b0378f52 100644 --- a/pkg/apic/provisioning/idp/provisioner_test.go +++ b/pkg/apic/provisioning/idp/provisioner_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "os" + "strings" "testing" "time" @@ -15,6 +16,12 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + testCredName = "my-cred" + testAppName = "my-app" + fullTemplate = config.OktaPlaceholderMPApplicationName + "-" + config.OktaPlaceholderOwningTeam + "-" + config.OktaPlaceholderCredentialName +) + func TestProvisioner(t *testing.T) { publicKey, err := os.ReadFile("../../../authz/oauth/testdata/publickey") assert.Nil(t, err) @@ -25,7 +32,7 @@ func TestProvisioner(t *testing.T) { defer s.Close() idpCfg := &config.IDPConfiguration{ Name: "test", - Type: "generic", + Type: oauth.TypeGeneric, AuthConfig: &config.IDPAuthConfiguration{ Type: config.Client, ClientID: "test", @@ -46,8 +53,7 @@ func TestProvisioner(t *testing.T) { err = idpReg.RegisterProvider(context.Background(), idpCfg, config.NewTLSConfig(), "", 30*time.Second) assert.Nil(t, err) - cases := []struct { - name string + cases := map[string]struct { idpType string appKey string credTokenURL string @@ -60,14 +66,12 @@ func TestProvisioner(t *testing.T) { expectRegistrationErr bool expectUnRegistrationErr bool }{ - { - name: "provisioner for non-IdP credential", + "provisioner for non-IdP credential": { credTokenURL: "", registrationResponseCode: http.StatusCreated, unRegistrationResponseCode: http.StatusNoContent, }, - { - name: "provisioner for IdP credential with client_credential", + "provisioner for IdP credential with client_credential": { credTokenURL: s.GetTokenURL(), tokenAuthMethod: config.ClientSecretPost, registrationResponseCode: http.StatusCreated, @@ -75,16 +79,14 @@ func TestProvisioner(t *testing.T) { appKey: "test-app-key", useRegistrationAccessToken: true, }, - { - name: "provisioner for IdP credential with private_key_jwt", + "provisioner for IdP credential with private_key_jwt": { credTokenURL: s.GetTokenURL(), tokenAuthMethod: config.PrivateKeyJWT, registrationResponseCode: http.StatusCreated, unRegistrationResponseCode: http.StatusNoContent, publicKey: string(publicKey), }, - { - name: "provisioner for IdP credential with tls_client_auth", + "provisioner for IdP credential with tls_client_auth": { credTokenURL: s.GetTokenURL(), tokenAuthMethod: config.TLSClientAuth, registrationResponseCode: http.StatusCreated, @@ -92,12 +94,12 @@ func TestProvisioner(t *testing.T) { certificate: string(certificate), }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { + for name, tc := range cases { + t.Run(name, func(t *testing.T) { app := management.NewManagedApplication("", "") app.Spec.Security.EncryptionKey = tc.appKey cred := management.NewCredential("", "") - cred.Spec.Data = map[string]interface{}{ + cred.Spec.Data = map[string]any{ IDPTokenURL: tc.credTokenURL, provisioning.OauthTokenAuthMethod: tc.tokenAuthMethod, provisioning.OauthJwks: tc.publicKey, @@ -134,7 +136,7 @@ func TestProvisioner(t *testing.T) { assert.NotEmpty(t, cr.registrationAccessToken) assert.NotEmpty(t, details) } - cred.Data = map[string]interface{}{ + cred.Data = map[string]any{ provisioning.OauthClientID: data.GetClientID(), provisioning.OauthRegistrationToken: cr.registrationAccessToken, } @@ -154,3 +156,144 @@ func TestProvisioner(t *testing.T) { }) } } + +type mockProvider struct { + cfg config.IDPConfig + capturedScopes []string + capturedGrant string + capturedName string +} + +func (m *mockProvider) GetName() string { return "" } +func (m *mockProvider) GetTitle() string { return "" } +func (m *mockProvider) GetIssuer() string { return "" } +func (m *mockProvider) GetTokenEndpoint() string { return "" } +func (m *mockProvider) GetMTLSTokenEndpoint() string { return "" } +func (m *mockProvider) GetAuthorizationEndpoint() string { return "" } +func (m *mockProvider) GetSupportedScopes() []string { return nil } +func (m *mockProvider) GetSupportedGrantTypes() []string { return nil } +func (m *mockProvider) GetSupportedTokenAuthMethods() []string { return nil } +func (m *mockProvider) GetSupportedResponseMethod() []string { return nil } +func (m *mockProvider) Validate() error { return nil } +func (m *mockProvider) GetConfig() config.IDPConfig { return m.cfg } +func (m *mockProvider) GetMetadata() *oauth.AuthorizationServerMetadata { return nil } +func (m *mockProvider) GetIDPResourceName() string { return "" } + +func (m *mockProvider) RegisterClient(meta oauth.ClientMetadata) (oauth.ClientMetadata, error) { + m.capturedName = meta.GetClientName() + result, err := oauth.NewClientMetadataBuilder().SetClientName(meta.GetClientName()).Build() + return result, err +} + +func (m *mockProvider) UnregisterClient(clientID, accessToken, registrationClientURI string, scopes []string, grantType string) error { + m.capturedScopes = scopes + m.capturedGrant = grantType + return nil +} + +func TestScopeGrantTypeThreading(t *testing.T) { + cases := map[string]struct { + scopes []string + grantType string + }{ + "multiple scopes with client credentials grant are passed to provider": { + scopes: []string{"scope1", "scope2"}, + grantType: oauth.GrantTypeClientCredentials, + }, + "single scope with authorization code grant is passed to provider": { + scopes: []string{"read:api"}, + grantType: oauth.GrantTypeAuthorizationCode, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + mock := &mockProvider{cfg: nil} + cred := management.NewCredential("", "") + p := &provisioner{ + app: management.NewManagedApplication("", ""), + credential: cred, + idpProvider: mock, + credentialData: &credData{ + scopes: tc.scopes, + grantTypes: []string{tc.grantType}, + }, + } + assert.NoError(t, p.UnregisterClient()) + assert.Equal(t, tc.scopes, mock.capturedScopes) + assert.Equal(t, tc.grantType, mock.capturedGrant) + }) + } +} + +func TestAppNameTemplate(t *testing.T) { + cases := map[string]struct { + cfg config.IDPConfig + appName string + teamName string + credName string + wantName string + wantErr bool + }{ + "nil config falls back to credential name": { + cfg: nil, + credName: testCredName, + wantName: testCredName, + }, + "non-okta IDP type falls back to credential name": { + cfg: &config.IDPConfiguration{Type: oauth.TypeGeneric}, + credName: testCredName, + wantName: testCredName, + }, + "template with all fields set": { + cfg: &config.IDPConfiguration{Type: oauth.TypeOkta, Okta: &config.OktaIDPConfiguration{AppNameTemplate: fullTemplate}}, + appName: testAppName, + teamName: "my-team", + credName: testCredName, + wantName: "my-app-my-team-my-cred", + }, + "empty team name collapses double dash after normalize": { + cfg: &config.IDPConfiguration{Type: oauth.TypeOkta, Okta: &config.OktaIDPConfiguration{AppNameTemplate: fullTemplate}}, + appName: testAppName, + teamName: "", + credName: testCredName, + wantName: "my-app-my-cred", + }, + "name exactly 100 chars passes": { + cfg: &config.IDPConfiguration{Type: oauth.TypeOkta, Okta: &config.OktaIDPConfiguration{AppNameTemplate: fullTemplate}}, + appName: strings.Repeat("a", 48), + teamName: strings.Repeat("b", 49), + credName: "c", + wantName: strings.Repeat("a", 48) + "-" + strings.Repeat("b", 49) + "-c", + }, + "name exceeds 100 chars returns error": { + cfg: &config.IDPConfiguration{Type: oauth.TypeOkta, Okta: &config.OktaIDPConfiguration{AppNameTemplate: fullTemplate}}, + appName: strings.Repeat("a", 50), + teamName: strings.Repeat("b", 49), + credName: strings.Repeat("c", 5), + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + mock := &mockProvider{cfg: tc.cfg} + app := management.NewManagedApplication(tc.appName, "") + if tc.teamName != "" { + assert.NoError(t, util.SetAgentDetailsKey(app, agentDetailTeamName, tc.teamName)) + } + cred := management.NewCredential(tc.credName, "") + p := &provisioner{ + app: app, + credential: cred, + idpProvider: mock, + credentialData: &credData{}, + } + got, err := p.appClientName() + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.wantName, got) + }) + } +} diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 17a5a0231..04f8fd05e 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "strings" coreapi "github.com/Axway/agent-sdk/pkg/api" @@ -13,27 +12,69 @@ import ( const OktaAuthHeaderPrefix = "SSWS" -var logger = log.NewFieldLogger().WithComponent("oktaClient").WithPackage("sdk.agent.authz.oauth.clients") - -// Okta wraps Okta Management API calls. type Okta struct { BaseURL string APIToken string Client coreapi.Client + logger log.FieldLogger } -type oktaGroupSearchResult struct { - ID string `json:"id"` - Profile oktaGroupProfile `json:"profile"` +type oktaPolicyListResult struct { + ID string `json:"id"` + Name string `json:"name"` } -type oktaGroupProfile struct { - Name string `json:"name"` +type oktaPolicyConditionsClients struct { + Include []string `json:"include"` } -type oktaPolicyListResult struct { - ID string `json:"id"` - Name string `json:"name"` +type oktaPolicyConditions struct { + Clients *oktaPolicyConditionsClients `json:"clients,omitempty"` +} + +type oktaCreatePolicyRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Priority int `json:"priority"` + Conditions oktaPolicyConditions `json:"conditions"` +} + +type oktaPolicyRuleConditionGroups struct { + Include []string `json:"include"` +} + +type oktaPolicyRuleConditionPeople struct { + Groups oktaPolicyRuleConditionGroups `json:"groups"` +} + +type oktaPolicyRuleConditionGrantTypes struct { + Include []string `json:"include"` +} + +type oktaPolicyRuleConditionScopes struct { + Include []string `json:"include"` +} + +type oktaPolicyRuleConditions struct { + People oktaPolicyRuleConditionPeople `json:"people"` + GrantTypes oktaPolicyRuleConditionGrantTypes `json:"grantTypes"` + Scopes oktaPolicyRuleConditionScopes `json:"scopes"` +} + +type oktaPolicyRuleActionToken struct { + AccessTokenLifetimeMinutes int `json:"accessTokenLifetimeMinutes"` +} + +type oktaPolicyRuleActions struct { + Token oktaPolicyRuleActionToken `json:"token"` +} + +type oktaCreatePolicyRuleRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Conditions oktaPolicyRuleConditions `json:"conditions"` + Actions oktaPolicyRuleActions `json:"actions"` } func New(apiClient coreapi.Client, baseURL, apiToken string) *Okta { @@ -44,6 +85,7 @@ func New(apiClient coreapi.Client, baseURL, apiToken string) *Okta { BaseURL: baseURL, APIToken: apiToken, Client: apiClient, + logger: log.NewFieldLogger().WithComponent("oktaClient").WithPackage("sdk.agent.authz.oauth.clients"), } } @@ -108,73 +150,7 @@ func isStatus(code int, allowed ...int) bool { return false } - - -func (o *Okta) FindGroupByName(groupName string) (string, error) { - endpoint := fmt.Sprintf("%s/api/v1/groups?q=%s", o.BaseURL, url.QueryEscape(groupName)) - resp, err := o.doRequest(coreapi.GET, endpoint, nil) - if err != nil { - return "", err - } - if !isStatus(resp.Code, http.StatusOK) { - return "", o.unexpectedStatusError(coreapi.GET, endpoint, resp) - } - - var groups []oktaGroupSearchResult - if err := json.Unmarshal(resp.Body, &groups); err != nil { - return "", err - } - for _, g := range groups { - if g.Profile.Name == groupName { - return g.ID, nil - } - } - return "", nil -} - -func (o *Okta) AssignGroupToApp(appID, groupID string) error { - endpoint := fmt.Sprintf("%s/api/v1/apps/%s/groups/%s", o.BaseURL, appID, groupID) - resp, err := o.doRequest(coreapi.PUT, endpoint, nil) - if err != nil { - return err - } - if resp.Code == http.StatusConflict { - logger. - WithField("appID", appID). - WithField("groupID", groupID). - Warn("group assignment already exists") - return nil - } - if !isStatus(resp.Code, http.StatusOK, http.StatusCreated, http.StatusNoContent) { - return o.unexpectedStatusError(coreapi.PUT, endpoint, resp) - } - return nil -} - -func (o *Okta) UnassignGroupFromApp(appID, groupID string) error { - endpoint := fmt.Sprintf("%s/api/v1/apps/%s/groups/%s", o.BaseURL, appID, groupID) - resp, err := o.doRequest(coreapi.DELETE, endpoint, nil) - if err != nil { - return err - } - if resp.Code == http.StatusNotFound { - logger. - WithField("appID", appID). - WithField("groupID", groupID). - Warn("group assignment not found during unassign. Assuming already unassigned") - return nil - } - if !isStatus(resp.Code, http.StatusOK, http.StatusNoContent) { - return o.unexpectedStatusError(coreapi.DELETE, endpoint, resp) - } - return nil -} - -// FindPolicyByName returns the policy object for the given policy name on the authorization server. -// Returns nil if not found. -// -// Note: This does a list call to locate the policy ID and then retrieves the policy by ID -// so callers can update it without needing an additional fetch. +// Two-step: list to find the policy ID, then fetch the full object so callers can update it without an extra round-trip. func (o *Okta) FindPolicyByName(authServerID, policyName string) (map[string]interface{}, error) { policyName = strings.TrimSpace(policyName) if authServerID == "" || policyName == "" { @@ -206,7 +182,6 @@ func (o *Okta) FindPolicyByName(authServerID, policyName string) (map[string]int return policy, nil } -// UpdatePolicy updates an existing authorization server policy. func (o *Okta) UpdatePolicy(authServerID, policyID string, policy map[string]interface{}) error { if authServerID == "" || policyID == "" { return nil @@ -222,23 +197,20 @@ func (o *Okta) UpdatePolicy(authServerID, policyID string, policy map[string]int return nil } -// AssignClientToPolicy updates the policy-level "Assigned to clients" list to include the given client. -// If the policy is already assigned to ALL_CLIENTS or already includes the client, it no-ops. -// -// The policy map is modified in-place and then persisted via UpdatePolicy. +// No-ops if the policy already includes the client or is assigned to ALL_CLIENTS. func (o *Okta) AssignClientToPolicy(authServerID string, policy map[string]interface{}, clientID string) error { clientID = strings.TrimSpace(clientID) if authServerID == "" || policy == nil || clientID == "" { return fmt.Errorf("invalid input for policy assignment") } - + policyID, _ := policy["id"].(string) policyID = strings.TrimSpace(policyID) if policyID == "" { return fmt.Errorf("invalid input for policy assignment") } - policyLogger := logger. + policyLogger := o.logger. WithField("authServerID", authServerID). WithField("policyID", policyID). WithField("clientID", clientID) @@ -258,13 +230,11 @@ func (o *Okta) AssignClientToPolicy(authServerID string, policy map[string]inter return nil } - // check if policy has all clients configured if includeHasAllClients(include) { policyLogger.Trace("policy assignment already includes ALL_CLIENTS. Skipping client-specific policy update") return nil } - // check if client is already included in policy assignment if includeHasClient(include, clientID) { policyLogger.Trace("policy assignment already includes client. Skipping client-specific policy update") return nil @@ -273,6 +243,136 @@ func (o *Okta) AssignClientToPolicy(authServerID string, policy map[string]inter return o.UpdatePolicy(authServerID, policyID, policy) } +func (o *Okta) CreatePolicy(authServerID, name string, priority int, clientID string) (map[string]interface{}, error) { + endpoint := o.authServerPoliciesEndpoint(authServerID) + o.logger. + WithField("authServerID", authServerID). + WithField("policyName", name). + WithField("endpoint", endpoint). + Trace("creating Okta authorization server policy") + req := oktaCreatePolicyRequest{ + Name: name, + Type: "OAUTH_AUTHORIZATION_POLICY", + Status: "ACTIVE", + Priority: priority, + Conditions: oktaPolicyConditions{ + Clients: &oktaPolicyConditionsClients{Include: []string{clientID}}, + }, + } + resp, err := o.doRequest(coreapi.POST, endpoint, req) + if err != nil { + return nil, err + } + if !isStatus(resp.Code, http.StatusCreated) { + return nil, o.unexpectedStatusError(coreapi.POST, endpoint, resp) + } + var policy map[string]interface{} + if err := json.Unmarshal(resp.Body, &policy); err != nil { + return nil, err + } + return policy, nil +} + +func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope string, tokenLifetimeMinutes int) error { + endpoint := fmt.Sprintf("%s/api/v1/authorizationServers/%s/policies/%s/rules", o.BaseURL, authServerID, policyID) + o.logger. + WithField("authServerID", authServerID). + WithField("policyID", policyID). + WithField("ruleName", name). + WithField("endpoint", endpoint). + Trace("creating Okta authorization server policy rule") + req := oktaCreatePolicyRuleRequest{ + Name: name, + Type: "RESOURCE_ACCESS", + Conditions: oktaPolicyRuleConditions{ + People: oktaPolicyRuleConditionPeople{Groups: oktaPolicyRuleConditionGroups{Include: []string{"EVERYONE"}}}, + GrantTypes: oktaPolicyRuleConditionGrantTypes{Include: []string{grantType}}, + Scopes: oktaPolicyRuleConditionScopes{Include: []string{scope}}, + }, + Actions: oktaPolicyRuleActions{ + Token: oktaPolicyRuleActionToken{AccessTokenLifetimeMinutes: tokenLifetimeMinutes}, + }, + } + resp, err := o.doRequest(coreapi.POST, endpoint, req) + if err != nil { + return err + } + if !isStatus(resp.Code, http.StatusCreated) { + return o.unexpectedStatusError(coreapi.POST, endpoint, resp) + } + return nil +} + +// The policy is never deleted, even when the include list becomes empty. +func (o *Okta) RemoveClientFromPolicy(authServerID string, policy map[string]interface{}, clientID string) error { + clientID = strings.TrimSpace(clientID) + policyID, _ := policy["id"].(string) + policyID = strings.TrimSpace(policyID) + if authServerID == "" || policyID == "" || clientID == "" { + return fmt.Errorf("invalid input for client removal from policy") + } + o.logger. + WithField("authServerID", authServerID). + WithField("policyID", policyID). + WithField("clientID", clientID). + Trace("removing client from Okta authorization server policy") + + conditions := ensureMap(policy, "conditions") + clients := ensureMap(conditions, "clients") + + includeRaw, _ := clients["include"] + include, ok := includeRaw.([]interface{}) + if !ok || !includeHasClient(include, clientID) { + return nil + } + + filtered := make([]interface{}, 0, len(include)) + for _, v := range include { + s, _ := v.(string) + if strings.TrimSpace(s) != clientID { + filtered = append(filtered, v) + } + } + clients["include"] = filtered + return o.UpdatePolicy(authServerID, policyID, policy) +} + +// DeactivateApp deactivates an Okta application. A 404 response is treated as success. +// DeactivateApp must be called before DeleteApp. +func (o *Okta) DeactivateApp(appID string) error { + endpoint := fmt.Sprintf("%s/api/v1/apps/%s/lifecycle/deactivate", o.BaseURL, appID) + o.logger.WithField("appID", appID).WithField("endpoint", endpoint).Trace("deactivating Okta app") + resp, err := o.doRequest(coreapi.POST, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusOK, http.StatusNoContent) { + return o.unexpectedStatusError(coreapi.POST, endpoint, resp) + } + return nil +} + +// DeleteApp deletes an Okta application. A 404 response is treated as success. +// DeactivateApp must be called before this method. +func (o *Okta) DeleteApp(appID string) error { + endpoint := fmt.Sprintf("%s/api/v1/apps/%s", o.BaseURL, appID) + o.logger.WithField("appID", appID).WithField("endpoint", endpoint).Trace("deleting Okta app") + resp, err := o.doRequest(coreapi.DELETE, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusNoContent) { + return o.unexpectedStatusError(coreapi.DELETE, endpoint, resp) + } + return nil +} + func ensureMap(parent map[string]interface{}, key string) map[string]interface{} { child, ok := parent[key].(map[string]interface{}) if ok && child != nil { diff --git a/pkg/authz/oauth/clients/okta_test.go b/pkg/authz/oauth/clients/okta_test.go index 93b48a5d7..61e8e1222 100644 --- a/pkg/authz/oauth/clients/okta_test.go +++ b/pkg/authz/oauth/clients/okta_test.go @@ -1,86 +1,277 @@ package clients import ( + "encoding/json" + "io" "net/http" "net/http/httptest" "testing" coreapi "github.com/Axway/agent-sdk/pkg/api" + "github.com/stretchr/testify/assert" ) -func TestOktaAPIStatusHandling(t *testing.T) { +const ( + removeClientID = "remove-me" + caseErrForbidden = "returns error on forbidden" + testPolicyName = "my-policy" + testClientID = "client1" + testPriority = 2 + testRuleName = "rule-1" + testGrantType = "client_credentials" + testRuleScope = "read:api" + testLifetime = 90 +) + +func newTestOktaClient(t *testing.T, handler http.HandlerFunc) (*Okta, func()) { + t.Helper() + ts := httptest.NewServer(handler) + client := New(coreapi.NewClient(nil, ""), ts.URL, "token") + return client, ts.Close +} + +func assertOktaErr(t *testing.T, err error, wantErr bool) { + t.Helper() + if wantErr && err == nil { + t.Fatal("expected an error but got nil") + return + } + if !wantErr && err != nil { + t.Fatalf("expected no error but got: %v", err) + } +} + +func TestOktaUpdatePolicy(t *testing.T) { cases := map[string]struct { - handler http.HandlerFunc - call func(client *Okta) error - wantErr bool - expectedMethod string + code int + wantErr bool }{ - "AssignGroupToApp returns error on forbidden": { - expectedMethod: http.MethodPut, - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("forbidden")) - }, - call: func(client *Okta) error { - return client.AssignGroupToApp("app123", "group456") + caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + "returns nil on ok": {code: http.StatusOK, wantErr: false}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.UpdatePolicy("as1", "pol1", map[string]interface{}{"id": "pol1"}), tc.wantErr) + }) + } +} + +func TestOktaCreatePolicy(t *testing.T) { + cases := map[string]struct { + code int + body string + wantErr bool + }{ + "returns created policy on 201": {code: http.StatusCreated, body: `{"id":"pol-new","name":"my-policy"}`, wantErr: false}, + caseErrForbidden: {code: http.StatusForbidden, body: `forbidden`, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(tc.body)) + }) + defer close() + _, err := client.CreatePolicy("as1", testPolicyName, 1, testClientID) + assertOktaErr(t, err, tc.wantErr) + }) + } +} + +func TestOktaCreatePolicyRequestBody(t *testing.T) { + var captured []byte + client, teardown := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + captured, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"pol1"}`)) + }) + defer teardown() + _, err := client.CreatePolicy("as1", testPolicyName, testPriority, testClientID) + assertOktaErr(t, err, false) + + var body map[string]interface{} + assert.NoError(t, json.Unmarshal(captured, &body)) + assert.Equal(t, "OAUTH_AUTHORIZATION_POLICY", body["type"]) + assert.Equal(t, "ACTIVE", body["status"]) + assert.EqualValues(t, testPriority, body["priority"]) + + conditions, _ := body["conditions"].(map[string]interface{}) + clients, _ := conditions["clients"].(map[string]interface{}) + include, _ := clients["include"].([]interface{}) + assert.Len(t, include, 1) + assert.Equal(t, testClientID, include[0]) + for _, v := range include { + assert.NotEqual(t, "ALL_CLIENTS", v) + } +} + +func TestOktaCreatePolicyRule(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + "returns nil on 201": {code: http.StatusCreated, wantErr: false}, + "returns error on bad req": {code: http.StatusBadRequest, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + err := client.CreatePolicyRule("as1", "pol1", "rule", testGrantType, testRuleScope, 60) + assertOktaErr(t, err, tc.wantErr) + }) + } +} + +func TestOktaCreatePolicyRuleRequestBody(t *testing.T) { + var captured []byte + client, teardown := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + captured, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + }) + defer teardown() + err := client.CreatePolicyRule("as1", "pol1", testRuleName, testGrantType, testRuleScope, testLifetime) + assertOktaErr(t, err, false) + + var body map[string]interface{} + assert.NoError(t, json.Unmarshal(captured, &body)) + assert.Equal(t, "RESOURCE_ACCESS", body["type"]) + + conds, _ := body["conditions"].(map[string]interface{}) + people, _ := conds["people"].(map[string]interface{}) + groups, _ := people["groups"].(map[string]interface{}) + groupIncludes, _ := groups["include"].([]interface{}) + assert.Equal(t, []interface{}{"EVERYONE"}, groupIncludes) + + grantTypes, _ := conds["grantTypes"].(map[string]interface{}) + grantIncludes, _ := grantTypes["include"].([]interface{}) + assert.Contains(t, grantIncludes, testGrantType) + + scopes, _ := conds["scopes"].(map[string]interface{}) + scopeIncludes, _ := scopes["include"].([]interface{}) + assert.Contains(t, scopeIncludes, testRuleScope) + + actions, _ := body["actions"].(map[string]interface{}) + token, _ := actions["token"].(map[string]interface{}) + assert.EqualValues(t, testLifetime, token["accessTokenLifetimeMinutes"]) +} + +func TestOktaRemoveClientFromPolicy(t *testing.T) { + policyWith := func(include ...interface{}) map[string]interface{} { + return map[string]interface{}{ + "id": "pol1", + "conditions": map[string]interface{}{ + "clients": map[string]interface{}{ + "include": include, + }, }, + } + } + + cases := map[string]struct { + code int + policy map[string]interface{} + wantErr bool + wantPUT bool + }{ + "returns nil on ok": { + code: http.StatusOK, + policy: policyWith("keep-me", removeClientID), + wantErr: false, + wantPUT: true, + }, + caseErrForbidden: { + code: http.StatusForbidden, + policy: policyWith("keep-me", removeClientID), wantErr: true, + wantPUT: true, }, - "AssignGroupToApp treats conflict as success": { - expectedMethod: http.MethodPut, - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusConflict) - w.Write([]byte("already assigned")) - }, - call: func(client *Okta) error { - return client.AssignGroupToApp("app123", "group456") - }, + "client not in list no PUT called": { + code: http.StatusOK, + policy: policyWith("other-client"), wantErr: false, + wantPUT: false, }, - "UnassignGroupFromApp treats not found as success": { - expectedMethod: http.MethodDelete, - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("not found")) - }, - call: func(client *Okta) error { - return client.UnassignGroupFromApp("app123", "group456") - }, + "empty include after removal policy not deleted": { + code: http.StatusOK, + policy: policyWith(removeClientID), wantErr: false, - }, - "UpdatePolicy returns error on forbidden": { - expectedMethod: http.MethodPut, - handler: func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - w.Write([]byte("forbidden")) - }, - call: func(client *Okta) error { - return client.UpdatePolicy("as1", "pol1", map[string]interface{}{"id": "pol1"}) - }, - wantErr: true, + wantPUT: true, }, } - for name, tc := range cases { tc := tc t.Run(name, func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if tc.expectedMethod != "" && r.Method != tc.expectedMethod { - w.WriteHeader(http.StatusMethodNotAllowed) - return + putCalled := false + deleteCalled := false + client, teardown := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + putCalled = true + w.WriteHeader(tc.code) + case http.MethodDelete: + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) } - tc.handler(w, r) - })) - defer ts.Close() - - client := New(coreapi.NewClient(nil, ""), ts.URL, "token") - err := tc.call(client) - if tc.wantErr && err == nil { - t.Fatalf("expected error, got nil") - } - if !tc.wantErr && err != nil { - t.Fatalf("expected nil error, got %v", err) - } + }) + defer teardown() + assertOktaErr(t, client.RemoveClientFromPolicy("as1", tc.policy, removeClientID), tc.wantErr) + assert.Equal(t, tc.wantPUT, putCalled, "PUT call expectation mismatch") + assert.False(t, deleteCalled, "policy must never be deleted") + }) + } +} + +func TestOktaDeactivateApp(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + "returns nil on 200": {code: http.StatusOK, wantErr: false}, + "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, + "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, + caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.DeactivateApp("app1"), tc.wantErr) + }) + } +} + +func TestOktaDeleteApp(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, + "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, + caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.DeleteApp("app1"), tc.wantErr) }) } } diff --git a/pkg/authz/oauth/genericprovider.go b/pkg/authz/oauth/genericprovider.go index 4f4064a69..887d90f3a 100644 --- a/pkg/authz/oauth/genericprovider.go +++ b/pkg/authz/oauth/genericprovider.go @@ -28,6 +28,6 @@ func (i *genericIDP) postProcessClientRegistration(clientRes ClientMetadata, idp return nil } -func (i *genericIDP) postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client) error { +func (i *genericIDP) postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client, scopes []string, grantType string) error { return nil } diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index ad723224c..d4aa55805 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -9,6 +9,7 @@ import ( coreapi "github.com/Axway/agent-sdk/pkg/api" "github.com/Axway/agent-sdk/pkg/authz/oauth/clients" corecfg "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/util/log" ) const ( @@ -20,7 +21,9 @@ const ( oktaSpa = "okta-spa" ) -type okta struct{} +type okta struct { + logger log.FieldLogger +} func oktaBaseURLFromMetadataURL(metadataURL string) (string, error) { if metadataURL == "" { @@ -57,136 +60,92 @@ func oktaAuthServerIDFromMetadataURL(metadataURL string) (string, error) { return "", fmt.Errorf("unable to determine Okta authorization server from metadata URL") } -func validateOktaConfiguredResources(idp corecfg.IDPConfig, apiClient coreapi.Client) error { - groupName, policyName := oktaValidationNames(idp) - if groupName == "" && policyName == "" { - return nil - } - - apiToken := oktaManagementAPIToken(idp) - if apiToken == "" { - return fmt.Errorf("okta group/policy validation requires a management API access token. Set %q in the IDP auth configuration", "auth.accessToken") - } - - metadataURL := idp.GetMetadataURL() - oktaClient, err := oktaClientFromMetadataURL(apiClient, metadataURL, apiToken) - if err != nil { - return err - } - - authServerID := "" - if policyName != "" { - authServerID, err = oktaAuthServerIDFromMetadataURL(metadataURL) - if err != nil { - return err - } +func normalizeGrantType(grantType string) string { + switch grantType { + case GrantTypeClientCredentials: + return "clientcredentials" + case GrantTypeAuthorizationCode: + return "authorizationcode" + case GrantTypeImplicit: + return "implicitgrant" + default: + return strings.ReplaceAll(grantType, "_", "") } - - if err := validateOktaGroupExists(oktaClient, groupName); err != nil { - return err - } - if err := validateOktaPolicyExists(oktaClient, authServerID, policyName); err != nil { - return err - } - return nil } -func oktaValidationNames(idp corecfg.IDPConfig) (groupName, policyName string) { - return strings.TrimSpace(idp.GetOktaGroup()), strings.TrimSpace(idp.GetOktaPolicy()) -} - -func oktaManagementAPIToken(idp corecfg.IDPConfig) string { - authCfg := idp.GetAuthConfig() - if authCfg == nil { - return "" - } - return strings.TrimSpace(authCfg.GetAccessToken()) -} - -func oktaClientFromMetadataURL(apiClient coreapi.Client, metadataURL, apiToken string) (*clients.Okta, error) { - baseURL, err := oktaBaseURLFromMetadataURL(metadataURL) - if err != nil { - return nil, err +func (i *okta) tokenLifetimeMinutes(idp corecfg.IDPConfig) int { + cfg, ok := idp.(interface{ GetTokenLifetimeMinutes() int }) + if !ok { + return 60 } - if baseURL == "" { - return nil, fmt.Errorf("invalid okta metadata URL: %q", metadataURL) + if v := cfg.GetTokenLifetimeMinutes(); v > 0 { + return v } - return clients.New(apiClient, baseURL, apiToken), nil + return 60 } -func validateOktaGroupExists(oktaClient *clients.Okta, groupName string) error { - if strings.TrimSpace(groupName) == "" { - return nil - } - groupID, err := oktaClient.FindGroupByName(groupName) - if err != nil { - return err +func (i *okta) policyPriority(idp corecfg.IDPConfig) int { + cfg, ok := idp.(interface{ GetPolicyPriority() int }) + if !ok { + return 1 } - if groupID == "" { - return fmt.Errorf("configured okta group %q was not found", groupName) + if v := cfg.GetPolicyPriority(); v > 0 { + return v } - return nil + return 1 } -func validateOktaPolicyExists(oktaClient *clients.Okta, authServerID, policyName string) error { - if strings.TrimSpace(policyName) == "" { - return nil +func policyNameTemplate(idp corecfg.IDPConfig) string { + cfg, ok := idp.(interface{ GetPolicyNameTemplate() string }) + if !ok { + return corecfg.OktaPlaceholderScope + "-" + corecfg.OktaPlaceholderOAuthFlow } - if authServerID == "" { - return fmt.Errorf("unable to determine Okta authorization server from metadata URL") + if t := cfg.GetPolicyNameTemplate(); t != "" { + return t } - policy, err := oktaClient.FindPolicyByName(authServerID, policyName) - if err != nil { - return err - } - if policy == nil { - return fmt.Errorf("configured okta policy %q was not found on authorization server %q", policyName, authServerID) - } - return nil + return corecfg.OktaPlaceholderScope + "-" + corecfg.OktaPlaceholderOAuthFlow } -// postProcessClientRegistration handles Okta provisioning after client registration func (i *okta) postProcessClientRegistration(clientRes ClientMetadata, idp corecfg.IDPConfig, apiClient coreapi.Client) error { + i.logger.WithField("clientID", clientRes.GetClientID()).Debug("running Okta post-registration hook") + metadataURL := idp.GetMetadataURL() var apiToken string if authCfg := idp.GetAuthConfig(); authCfg != nil { apiToken = authCfg.GetAccessToken() } - groupName := idp.GetOktaGroup() - policyName := idp.GetOktaPolicy() baseURL, err := oktaBaseURLFromMetadataURL(metadataURL) if err != nil { return err } if baseURL == "" || apiToken == "" { - return nil // skip if not configured + return nil } - oktaClient := clients.New(apiClient, baseURL, apiToken) - if err := i.handleGroupAssignment(oktaClient, clientRes, groupName); err != nil { + authServerID, err := oktaAuthServerIDFromMetadataURL(metadataURL) + if err != nil { return err } - authServerID := "" - if strings.TrimSpace(policyName) != "" { - authServerID, err = oktaAuthServerIDFromMetadataURL(metadataURL) - if err != nil { - return err - } + oktaClient := clients.New(apiClient, baseURL, apiToken) + + grantType := "" + if gt := clientRes.GetGrantTypes(); len(gt) > 0 { + grantType = gt[0] } - if err := i.handlePolicyAssignment(oktaClient, clientRes.GetClientID(), authServerID, policyName); err != nil { + + if err := i.handlePerScopePolicyAssign(oktaClient, clientRes, idp, authServerID, grantType); err != nil { return err } + i.logger.WithField("clientID", clientRes.GetClientID()).Info("completed Okta post-registration hook") return nil } -// postProcessClientUnregister removes the app→group assignment for the client. -// Explicit policy cleanup is not required: when the group assignment is removed, Okta -// automatically removes the client from any policy include lists that were scoped to that -// group, so no separate policy deprovision step is needed. -func (i *okta) postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client) error { +func (i *okta) postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client, scopes []string, grantType string) error { + i.logger.WithField("clientID", clientID).Debug("running Okta post-unregister hook") + metadataURL := idp.GetMetadataURL() var apiToken string if authCfg := idp.GetAuthConfig(); authCfg != nil { @@ -198,99 +157,147 @@ func (i *okta) postProcessClientUnregister(clientID string, idp corecfg.IDPConfi return err } if baseURL == "" || apiToken == "" { - return nil // skip if not configured - } - - oktaClient := clients.New(apiClient, baseURL, apiToken) - return i.handleGroupUnassignment(oktaClient, clientID, idp.GetOktaGroup()) -} - -// handleGroupUnassignment unassigns the configured group from the app. -func (i *okta) handleGroupUnassignment(oktaClient *clients.Okta, clientID, groupName string) error { - groupName = strings.TrimSpace(groupName) - if groupName == "" { return nil } - groupID, err := oktaClient.FindGroupByName(groupName) + authServerID, err := oktaAuthServerIDFromMetadataURL(metadataURL) if err != nil { return err } - if groupID == "" { - return fmt.Errorf("configured okta group %q was not found during client cleanup", groupName) - } - if err := oktaClient.UnassignGroupFromApp(clientID, groupID); err != nil { - return err - } - return nil -} - -func (i *okta) getAuthorizationHeaderPrefix() string { - return clients.OktaAuthHeaderPrefix -} + oktaClient := clients.New(apiClient, baseURL, apiToken) -// handleGroupAssignment looks up the named group and assigns it to the app. -// Returns an error if the group is not found. Even though we fail fast at the start, we check again to guard against the possibility that the group was deleted between validation and assignment. -func (i *okta) handleGroupAssignment(oktaClient *clients.Okta, clientRes ClientMetadata, groupName string) error { - groupName = strings.TrimSpace(groupName) - if groupName == "" { - return nil - } - groupId, err := oktaClient.FindGroupByName(groupName) - if err != nil { + if err := oktaClient.DeactivateApp(clientID); err != nil { return err } - if groupId == "" { - return fmt.Errorf("configured okta group %q was not found", groupName) + if err := oktaClient.DeleteApp(clientID); err != nil { + return err } - if err := oktaClient.AssignGroupToApp(clientRes.GetClientID(), groupId); err != nil { + + if err := i.handlePerScopePolicyUnassign(oktaClient, clientID, scopes, idp, authServerID, grantType); err != nil { return err } + + i.logger.WithField("clientID", clientID).Info("completed Okta post-unregister hook") return nil } -// handlePolicyAssignment looks up the named policy on the authorization server and assigns the client to it. -// Returns an error if the policy is not found. Even though we fail fast at the start, we check again to guard against the possibility that the policy was deleted between validation and assignment. -func (i *okta) handlePolicyAssignment(oktaClient *clients.Okta, clientID, authServerID string, policyName string) error { - policyName = strings.TrimSpace(policyName) - if policyName == "" { - return nil +func (i *okta) handlePerScopePolicyAssign( + oktaClient *clients.Okta, + clientRes ClientMetadata, + idp corecfg.IDPConfig, + authServerID string, + grantType string, +) error { + i.logger. + WithField("clientID", clientRes.GetClientID()). + WithField("authServerID", authServerID). + Trace("handling per-scope policy assignment") + + normalizedFlow := normalizeGrantType(grantType) + template := policyNameTemplate(idp) + scopes := clientRes.GetScopes() + + replacer := strings.NewReplacer(corecfg.OktaPlaceholderOAuthFlow, normalizedFlow) + for _, scope := range scopes { + policyName := replacer.Replace(strings.ReplaceAll(template, corecfg.OktaPlaceholderScope, scope)) + if err := i.assignScopePolicy(oktaClient, clientRes, idp, authServerID, grantType, policyName, scope); err != nil { + return err + } } - if authServerID == "" { - return fmt.Errorf("unable to determine Okta authorization server from metadata URL") + + return nil +} + +func (i *okta) assignScopePolicy( + oktaClient *clients.Okta, + clientRes ClientMetadata, + idp corecfg.IDPConfig, + authServerID, grantType, policyName, scope string, +) error { + if len(policyName) > 100 { + return fmt.Errorf("Okta policy name exceeds 100-character limit: %s (%d chars)", policyName, len(policyName)) } + + i.logger.WithField("policyName", policyName).Trace("looking up policy") + policy, err := oktaClient.FindPolicyByName(authServerID, policyName) if err != nil { return err } + if policy == nil { - return fmt.Errorf("configured okta policy %q was not found on authorization server %q", policyName, authServerID) + i.logger.WithField("policyName", policyName).Trace("policy not found, creating") + createdPolicy, err := oktaClient.CreatePolicy(authServerID, policyName, i.policyPriority(idp), clientRes.GetClientID()) + if err != nil { + return err + } + policyID, _ := createdPolicy["id"].(string) + if err := oktaClient.CreatePolicyRule(authServerID, policyID, policyName, grantType, scope, i.tokenLifetimeMinutes(idp)); err != nil { + return err + } + i.logger.WithField("policyName", policyName).Trace("created policy and rule") + return nil } - policyID, _ := policy["id"].(string) - policyID = strings.TrimSpace(policyID) - if policyID == "" { - return fmt.Errorf("okta policy %q has no id field", policyName) + + i.logger.WithField("policyName", policyName).Trace("policy found, assigning client") + return oktaClient.AssignClientToPolicy(authServerID, policy, clientRes.GetClientID()) +} + +func (i *okta) handlePerScopePolicyUnassign( + oktaClient *clients.Okta, + clientID string, + scopes []string, + idp corecfg.IDPConfig, + authServerID string, + grantType string, +) error { + i.logger. + WithField("clientID", clientID). + WithField("authServerID", authServerID). + Trace("handling per-scope policy unassignment") + + normalizedFlow := normalizeGrantType(grantType) + template := policyNameTemplate(idp) + + for _, scope := range scopes { + policyName := strings.NewReplacer(corecfg.OktaPlaceholderScope, scope, corecfg.OktaPlaceholderOAuthFlow, normalizedFlow).Replace(template) + i.logger.WithField("policyName", policyName).Trace("looking up policy for removal") + + policy, err := oktaClient.FindPolicyByName(authServerID, policyName) + if err != nil { + return err + } + + if policy == nil { + i.logger. + WithField("scope", scope). + WithField("policyName", policyName). + Trace("policy not found for scope during unassign, skipping") + continue + } + + i.logger. + WithField("clientID", clientID). + WithField("policyName", policyName). + Trace("removing client from policy") + if err := oktaClient.RemoveClientFromPolicy(authServerID, policy, clientID); err != nil { + return err + } } - // Update policy-level client assignment (no rules). - return oktaClient.AssignClientToPolicy(authServerID, policy, clientID) + + return nil } -// validateExtraProperties validates Okta-specific extra properties and sets defaults func (i *okta) validateExtraProperties(extraProps map[string]interface{}) error { pkceRequired, _ := extraProps[oktaPKCERequired].(bool) appType, _ := extraProps[oktaApplicationType].(string) - // Validate that if PKCE is required and application_type is explicitly set, it must be 'browser' if pkceRequired && appType != "" && appType != oktaAppTypeBrowser { return fmt.Errorf("when %s is true, %s must be '%s' or unset, got '%s'", oktaPKCERequired, oktaApplicationType, oktaAppTypeBrowser, appType) } - // Set default application_type only if not explicitly set by user - // Check appType == "" to preserve valid explicit user choices (e.g., "service") - // Default to 'web', override to 'browser' if PKCE is required - // Note: May be further overridden to 'service' in preProcessClientRequest for client credentials flows if appType == "" { extraProps[oktaApplicationType] = oktaAppTypeWeb if pkceRequired { @@ -308,8 +315,6 @@ func (i *okta) preProcessClientRequest(clientRequest *clientMetadata) { pkceRequired, _ := clientRequest.extraProperties[oktaPKCERequired].(bool) - // Override application_type to 'service' for client credentials flows - // (validateExtraProperties sets default to 'web' or 'browser') if slices.Contains(clientRequest.GrantTypes, GrantTypeClientCredentials) { clientRequest.extraProperties[oktaApplicationType] = oktaAppTypeService if len(clientRequest.ResponseTypes) == 0 { @@ -321,3 +326,7 @@ func (i *okta) preProcessClientRequest(clientRequest *clientMetadata) { clientRequest.TokenEndpointAuthMethod = "none" } } + +func (i *okta) getAuthorizationHeaderPrefix() string { + return clients.OktaAuthHeaderPrefix +} diff --git a/pkg/authz/oauth/oktaprovider_test.go b/pkg/authz/oauth/oktaprovider_test.go index ce4594d7b..3936e9086 100644 --- a/pkg/authz/oauth/oktaprovider_test.go +++ b/pkg/authz/oauth/oktaprovider_test.go @@ -3,6 +3,7 @@ package oauth import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -11,38 +12,47 @@ import ( coreapi "github.com/Axway/agent-sdk/pkg/api" corecfg "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/util/log" "github.com/stretchr/testify/assert" ) const ( - groupsEndpoint = "/api/v1/groups" - appGroupsEndpointBase = "/api/v1/apps/app123/groups/" - oktaGroupIDExisting = "00g-123" - oauthMetadataEndpoint = "/oauth2/authorizationID/.well-known/oauth-authorization-server" - oktaPoliciesEndpointByID = "/api/v1/authorizationServers/authorizationID/policies" - accessToken = "access-token" - oktaPolicyID = "pol-123" - oktaGroupID = "00g-123" - oktaPolicyEndpointByID = "/api/v1/authorizationServers/authorizationID/policies/pol-123" + testScope = "read:api" + testPolicyName = "read:api-clientcredentials" + testClientID = "app123" + accessToken = "access-token" + testAuthHeader = "SSWS " + accessToken + testPolicyPriority = 5 + defaultAppTemplate = corecfg.OktaPlaceholderMPApplicationName + "-" + corecfg.OktaPlaceholderOwningTeam + "-" + corecfg.OktaPlaceholderCredentialName + defaultPolicyTemplate = corecfg.OktaPlaceholderScope + "-" + corecfg.OktaPlaceholderOAuthFlow + normalizedClientCredentials = "clientcredentials" + normalizedAuthorizationCode = "authorizationcode" + normalizedImplicitGrant = "implicitgrant" + testAuthMethodNone = "none" + oauthMetadataEndpoint = "/oauth2/authorizationID/.well-known/oauth-authorization-server" + oktaPoliciesEndpointByID = "/api/v1/authorizationServers/authorizationID/policies" + oktaPolicyID = "pol-123" + oktaPolicyEndpointByID = "/api/v1/authorizationServers/authorizationID/policies/pol-123" + oktaPolicyRulesEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/rules" + oktaDeactivateEndpoint = "/api/v1/apps/app123/lifecycle/deactivate" + oktaDeleteEndpoint = "/api/v1/apps/app123" ) +// oktaScriptedServer drives httptest with per-route handlers and call-count tracking. type oktaScriptedServer struct { - mu sync.Mutex - expectedAuth string - calls map[string]int - routes map[string]http.HandlerFunc - strictRouting bool + mu sync.Mutex + expectedAuth string + calls map[string]int + routes map[string]http.HandlerFunc } func newOktaScriptedServer(t *testing.T, expectedAuth string, routes map[string]http.HandlerFunc) (*httptest.Server, *oktaScriptedServer) { t.Helper() - s := &oktaScriptedServer{ expectedAuth: expectedAuth, calls: make(map[string]int), routes: routes, } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() s.calls[r.Method+" "+r.URL.Path]++ @@ -55,16 +65,13 @@ func newOktaScriptedServer(t *testing.T, expectedAuth string, routes map[string] return } } - - key := r.Method + " " + r.URL.Path - if h, ok := routes[key]; ok { + if h, ok := routes[r.Method+" "+r.URL.Path]; ok { h(w, r) return } w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("not found")) })) - return ts, s } @@ -78,46 +85,11 @@ func (s *oktaScriptedServer) getCalls() map[string]int { return out } -type oktaGroupItem struct { - ID string - Name string -} - type oktaPolicyItem struct { ID string Name string } -func oktaGroupsSearchHandler(wantQ string, items []oktaGroupItem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if wantQ != "" { - if q := r.URL.Query().Get("q"); q != wantQ { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte("unexpected q")) - return - } - } - w.WriteHeader(http.StatusOK) - buf := make([]byte, 0, 256) - buf = append(buf, '[') - for i, item := range items { - if i > 0 { - buf = append(buf, ',') - } - buf = append(buf, []byte(fmt.Sprintf(`{"id":%q,"profile":{"name":%q}}`, item.ID, item.Name))...) - } - buf = append(buf, ']') - _, _ = w.Write(buf) - } -} - -func oktaAssignGroupHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{}`)) - } -} - func oktaPoliciesListHandler(items []oktaPolicyItem) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -127,28 +99,28 @@ func oktaPoliciesListHandler(items []oktaPolicyItem) http.HandlerFunc { if i > 0 { buf = append(buf, ',') } - buf = append(buf, []byte(fmt.Sprintf(`{"id":%q,"name":%q}`, item.ID, item.Name))...) + buf = fmt.Appendf(buf, `{"id":%q,"name":%q}`, item.ID, item.Name) } buf = append(buf, ']') _, _ = w.Write(buf) } } -func oktaPolicyGetHandler(policyID, policyName string, include []string) http.HandlerFunc { +func oktaPolicyGetHandler(policyID, name string, include []string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) includeJSON, _ := json.Marshal(include) - _, _ = w.Write([]byte(fmt.Sprintf(`{"id":%q,"name":%q,"conditions":{"clients":{"include":%s}}}`, policyID, policyName, string(includeJSON)))) + _, _ = fmt.Fprintf(w, `{"id":%q,"name":%q,"conditions":{"clients":{"include":%s}}}`, policyID, name, string(includeJSON)) } } func oktaPolicyPutMustIncludeClientHandler(clientID string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} + var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) - conds, _ := body["conditions"].(map[string]interface{}) - clients, _ := conds["clients"].(map[string]interface{}) - include, _ := clients["include"].([]interface{}) + conds, _ := body["conditions"].(map[string]any) + clients, _ := conds["clients"].(map[string]any) + include, _ := clients["include"].([]any) for _, v := range include { if s, ok := v.(string); ok && s == clientID { w.WriteHeader(http.StatusOK) @@ -161,15 +133,11 @@ func oktaPolicyPutMustIncludeClientHandler(clientID string) http.HandlerFunc { } } -func newIDPCredential(tsURL, group, policy string) *corecfg.IDPConfiguration { - credentialObj := &corecfg.IDPConfiguration{ +func newIDPCredential(tsURL string) *corecfg.IDPConfiguration { + return &corecfg.IDPConfiguration{ MetadataURL: tsURL + oauthMetadataEndpoint, AuthConfig: &corecfg.IDPAuthConfiguration{AccessToken: accessToken}, } - if strings.TrimSpace(group) != "" || strings.TrimSpace(policy) != "" { - credentialObj.Okta = &corecfg.OktaIDPConfiguration{Group: group, Policy: policy} - } - return credentialObj } func assertMinCalls(t *testing.T, calls map[string]int, wantMinCalls map[string]int) { @@ -181,188 +149,281 @@ func assertMinCalls(t *testing.T, calls map[string]int, wantMinCalls map[string] func TestOktaPostProcessClientRegistration(t *testing.T) { apiClient := coreapi.NewClient(nil, "") - oktaProvider := &okta{} + oktaProvider := &okta{logger: log.NewFieldLogger()} cases := map[string]struct { - oktaGroup string - oktaPolicy string + scopes []string + grantType string routes map[string]http.HandlerFunc wantMinCalls map[string]int wantErr bool }{ - "assigns existing group + records existing policy": { - oktaGroup: "MyAppUsers", - oktaPolicy: "MarketplacePolicy", + "creates new per-scope policy when none exists": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + groupsEndpoint: oktaGroupsSearchHandler("MyAppUsers", []oktaGroupItem{{ID: "00g-other", Name: "Other"}, {ID: oktaGroupIDExisting, Name: "MyAppUsers"}}), - http.MethodPut + " " + appGroupsEndpointBase + oktaGroupIDExisting: oktaAssignGroupHandler(), - http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: "MarketplacePolicy"}}), - http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, "MarketplacePolicy", []string{}), - http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler("app123"), + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q,"name":%q}`, oktaPolicyID, testPolicyName) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"rule-1"}`)) + }, }, wantMinCalls: map[string]int{ - http.MethodGet + " " + groupsEndpoint: 1, - http.MethodPut + " " + appGroupsEndpointBase + oktaGroupIDExisting: 1, - http.MethodGet + " " + oktaPoliciesEndpointByID: 1, - http.MethodGet + " " + oktaPolicyEndpointByID: 1, - http.MethodPut + " " + oktaPolicyEndpointByID: 1, + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodPost + " " + oktaPoliciesEndpointByID: 1, + http.MethodPost + " " + oktaPolicyRulesEndpoint: 1, }, }, - "infers auth server id for policy": { - oktaGroup: "MyAppUsers", - oktaPolicy: "MarketplacePolicy", + "assigns client to existing per-scope policy": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + groupsEndpoint: oktaGroupsSearchHandler("", []oktaGroupItem{{ID: oktaGroupIDExisting, Name: "MyAppUsers"}}), - http.MethodPut + " " + appGroupsEndpointBase + oktaGroupIDExisting: oktaAssignGroupHandler(), - http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: "MarketplacePolicy"}}), - http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, "MarketplacePolicy", []string{}), - http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler("app123"), + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{}), + http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), }, wantMinCalls: map[string]int{ - http.MethodGet + " " + groupsEndpoint: 1, - http.MethodPut + " " + appGroupsEndpointBase + oktaGroupIDExisting: 1, - http.MethodGet + " " + oktaPoliciesEndpointByID: 1, - http.MethodGet + " " + oktaPolicyEndpointByID: 1, - http.MethodPut + " " + oktaPolicyEndpointByID: 1, + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodPut + " " + oktaPolicyEndpointByID: 1, }, }, - "no actions when group/policy disabled": { + "no scopes results in no policy calls": { + scopes: []string{}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{}, wantMinCalls: map[string]int{}, }, - "error when group not found": { - oktaGroup: "NewGroup", + "policy name exactly 100 chars succeeds": { + scopes: []string{strings.Repeat("a", 82)}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + groupsEndpoint: oktaGroupsSearchHandler("NewGroup", nil), - }, - wantErr: true, - wantMinCalls: map[string]int{ - http.MethodGet + " " + groupsEndpoint: 1, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q}`, oktaPolicyID) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, }, + wantErr: false, + }, + "policy name length validation error": { + scopes: []string{"a-very-long-scope-name-that-when-combined-with-the-flow-name-exceeds-the-okta-100-char-limit-for-policies"}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{}, + wantErr: true, }, - "error when policy not found": { - oktaPolicy: "MissingPolicy", + "CreatePolicy error aborts registration": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: "pol-other", Name: "OtherPolicy"}}), + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, }, wantErr: true, - wantMinCalls: map[string]int{ - http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + }, + "CreatePolicyRule error aborts registration": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q,"name":%q}`, oktaPolicyID, testPolicyName) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, }, + wantErr: true, }, } for name, tc := range cases { - tc := tc t.Run(name, func(t *testing.T) { - expectedAuth := "SSWS access-token" - ts, scripted := newOktaScriptedServer(t, expectedAuth, tc.routes) + ts, scripted := newOktaScriptedServer(t, testAuthHeader, tc.routes) defer ts.Close() - credentialObj := newIDPCredential(ts.URL, tc.oktaGroup, tc.oktaPolicy) - clientRes := &clientMetadata{ClientID: "app123"} + credentialObj := newIDPCredential(ts.URL) + clientRes := &clientMetadata{ClientID: testClientID, Scope: tc.scopes, GrantTypes: []string{tc.grantType}} err := oktaProvider.postProcessClientRegistration(clientRes, credentialObj, apiClient) if tc.wantErr { assert.Error(t, err) - } else { - assert.NoError(t, err) + return } - + assert.NoError(t, err) assertMinCalls(t, scripted.getCalls(), tc.wantMinCalls) }) } } -func TestOktaPostProcessClientUnreg(t *testing.T) { +func TestOktaPostProcessClientUnregister(t *testing.T) { apiClient := coreapi.NewClient(nil, "") - oktaProvider := &okta{} + oktaProvider := &okta{logger: log.NewFieldLogger()} cases := map[string]struct { - oktaGroup string + scopes []string + grantType string routes map[string]http.HandlerFunc wantMinCalls map[string]int wantErr bool - errContains string }{ - "unassigns group when configured group exists": { - oktaGroup: "MyAppUsers", + "deactivates app, deletes app, removes from policy": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + groupsEndpoint: oktaGroupsSearchHandler("MyAppUsers", []oktaGroupItem{{ID: oktaGroupID, Name: "MyAppUsers"}}), - http.MethodDelete + " " + appGroupsEndpointBase + oktaGroupID: func(w http.ResponseWriter, r *http.Request) { + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{testClientID}), + http.MethodPut + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, }, wantMinCalls: map[string]int{ - http.MethodGet + " " + groupsEndpoint: 1, - http.MethodDelete + " " + appGroupsEndpointBase + oktaGroupID: 1, + http.MethodPost + " " + oktaDeactivateEndpoint: 1, + http.MethodDelete + " " + oktaDeleteEndpoint: 1, + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodPut + " " + oktaPolicyEndpointByID: 1, }, - wantErr: false, }, - "no cleanup when okta group is not configured": { - oktaGroup: "", - routes: map[string]http.HandlerFunc{}, - wantMinCalls: map[string]int{}, - wantErr: false, + "missing policy during unassign is skipped": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + }, + wantMinCalls: map[string]int{ + http.MethodPost + " " + oktaDeactivateEndpoint: 1, + http.MethodDelete + " " + oktaDeleteEndpoint: 1, + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + }, }, - "returns error when configured group is not found": { - oktaGroup: "MissingGroup", + "deactivate 404 is treated as success": { + scopes: []string{}, + grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + groupsEndpoint: oktaGroupsSearchHandler("MissingGroup", nil), + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, }, wantMinCalls: map[string]int{ - http.MethodGet + " " + groupsEndpoint: 1, + http.MethodPost + " " + oktaDeactivateEndpoint: 1, + http.MethodDelete + " " + oktaDeleteEndpoint: 1, }, - wantErr: true, - errContains: "configured okta group", }, } for name, tc := range cases { - tc := tc t.Run(name, func(t *testing.T) { - expectedAuth := "SSWS access-token" - ts, scripted := newOktaScriptedServer(t, expectedAuth, tc.routes) + ts, scripted := newOktaScriptedServer(t, testAuthHeader, tc.routes) defer ts.Close() + credentialObj := newIDPCredential(ts.URL) - credentialObj := &corecfg.IDPConfiguration{ - MetadataURL: ts.URL + oauthMetadataEndpoint, - Okta: &corecfg.OktaIDPConfiguration{Group: tc.oktaGroup}, - AuthConfig: &corecfg.IDPAuthConfiguration{AccessToken: accessToken}, - } - err := oktaProvider.postProcessClientUnregister("app123", credentialObj, apiClient) + err := oktaProvider.postProcessClientUnregister(testClientID, credentialObj, apiClient, tc.scopes, tc.grantType) if tc.wantErr { assert.Error(t, err) - if tc.errContains != "" { - assert.Contains(t, err.Error(), tc.errContains) - } - } else { - assert.NoError(t, err) + return } + assert.NoError(t, err) + assertMinCalls(t, scripted.getCalls(), tc.wantMinCalls) + }) + } +} - calls := scripted.getCalls() - for key, minCount := range tc.wantMinCalls { - assert.GreaterOrEqual(t, calls[key], minCount, "expected at least %d calls to %s", minCount, key) - } +func TestNormalizeGrantType(t *testing.T) { + cases := map[string]struct { + input string + want string + }{ + "client_credentials": {input: GrantTypeClientCredentials, want: normalizedClientCredentials}, + "authorization_code": {input: GrantTypeAuthorizationCode, want: normalizedAuthorizationCode}, + "implicit": {input: GrantTypeImplicit, want: normalizedImplicitGrant}, + "unknown strips underscores": {input: "custom_grant_type", want: "customgranttype"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, normalizeGrantType(tc.input)) + }) + } +} + +func TestOktaTokenLifetimeMinutes(t *testing.T) { + o := &okta{logger: log.NewFieldLogger()} + cases := map[string]struct { + cfg corecfg.IDPConfig + want int + }{ + "returns configured value": { + cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{TokenLifetimeMin: "90"}}, + want: 90, + }, + "returns default 60 when nil okta cfg": { + cfg: &corecfg.IDPConfiguration{}, + want: 60, + }, + "returns default 60 when zero value": { + cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{TokenLifetimeMin: "0"}}, + want: 60, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, o.tokenLifetimeMinutes(tc.cfg)) + }) + } +} + +func TestOktaPolicyPriority(t *testing.T) { + o := &okta{logger: log.NewFieldLogger()} + cases := map[string]struct { + cfg corecfg.IDPConfig + want int + }{ + "returns configured value": { + cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{PolicyPriority: "5"}}, + want: 5, + }, + "returns default 1 when nil okta cfg": { + cfg: &corecfg.IDPConfiguration{}, + want: 1, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, o.policyPriority(tc.cfg)) }) } } func TestOktaPostProcessClientRegUsesIDPAccessToken(t *testing.T) { - // We only need to prove that auth.accessToken from the IDP config is used to - // enable Okta post-processing (i.e. the hook does not early-return). - // Avoid making any Okta API calls by disabling all optional actions. ts := httptest.NewServer(nil) defer ts.Close() - credentialObj := &corecfg.IDPConfiguration{ - MetadataURL: ts.URL + oauthMetadataEndpoint, - AuthConfig: &corecfg.IDPAuthConfiguration{AccessToken: accessToken}, - } - apiClient := coreapi.NewClient(nil, "") - oktaProvider := &okta{} - clientRes := &clientMetadata{ClientID: "app123"} - err := oktaProvider.postProcessClientRegistration(clientRes, credentialObj, apiClient) + oktaProvider := &okta{logger: log.NewFieldLogger()} + clientRes := &clientMetadata{ClientID: testClientID} + err := oktaProvider.postProcessClientRegistration(clientRes, newIDPCredential(ts.URL), apiClient) assert.NoError(t, err) } @@ -374,28 +435,22 @@ func TestOktaBaseURLFromMetadataURL(t *testing.T) { }{ "empty metadata url returns error": { metadataURL: "", - want: "", wantErr: true, }, "okta issuer url returns scheme+host": { metadataURL: "https://integrator-1663282.okta.com/oauth2/authorizationid/.well-known/oauth-authorization-server", want: "https://integrator-1663282.okta.com", - wantErr: false, }, "invalid url returns error": { metadataURL: "://bad-url", - want: "", wantErr: true, }, "missing scheme/host returns error": { metadataURL: "/relative/path", - want: "", wantErr: true, }, } - for name, tc := range cases { - tc := tc t.Run(name, func(t *testing.T) { got, err := oktaBaseURLFromMetadataURL(tc.metadataURL) if tc.wantErr { @@ -410,175 +465,322 @@ func TestOktaBaseURLFromMetadataURL(t *testing.T) { func TestOktaPKCERequired(t *testing.T) { cases := map[string]struct { - pkceRequired bool - expectedValue bool + pkceRequired bool + want bool }{ - "PKCE required true": { - pkceRequired: true, - expectedValue: true, - }, - "PKCE required false": { - pkceRequired: false, - expectedValue: false, - }, + "PKCE required true": {pkceRequired: true, want: true}, + "PKCE required false": {pkceRequired: false, want: false}, } - for name, tc := range cases { t.Run(name, func(t *testing.T) { - props := map[string]interface{}{ - oktaPKCERequired: tc.pkceRequired, - } + props := map[string]any{oktaPKCERequired: tc.pkceRequired} c, err := NewClientMetadataBuilder(). SetClientName(oktaSpa). SetExtraProperties(props). Build() - assert.Nil(t, err) + assert.NoError(t, err) cm := c.(*clientMetadata) buf, err := json.Marshal(cm) - assert.Nil(t, err) - assert.NotNil(t, buf) + assert.NoError(t, err) - var out map[string]interface{} - err = json.Unmarshal(buf, &out) - assert.Nil(t, err) + var out map[string]any + assert.NoError(t, json.Unmarshal(buf, &out)) - // Should be a boolean, not a string val, ok := out[oktaPKCERequired] assert.True(t, ok) - assert.IsType(t, tc.expectedValue, val) - assert.Equal(t, tc.expectedValue, val) + assert.Equal(t, tc.want, val) }) } } func TestValidateOktaExtraProperties(t *testing.T) { cases := map[string]struct { - extraProps map[string]interface{} - expectError bool + extraProps map[string]any + wantErr bool }{ "Valid: PKCE with browser type": { - extraProps: map[string]interface{}{ - oktaPKCERequired: true, - oktaApplicationType: oktaAppTypeBrowser, - }, - expectError: false, + extraProps: map[string]any{oktaPKCERequired: true, oktaApplicationType: oktaAppTypeBrowser}, }, "Valid: PKCE without app type": { - extraProps: map[string]interface{}{ - oktaPKCERequired: true, - }, - expectError: false, + extraProps: map[string]any{oktaPKCERequired: true}, }, "Invalid: PKCE with service type": { - extraProps: map[string]interface{}{ - oktaPKCERequired: true, - oktaApplicationType: oktaAppTypeService, - }, - expectError: true, + extraProps: map[string]any{oktaPKCERequired: true, oktaApplicationType: oktaAppTypeService}, + wantErr: true, }, "Invalid: PKCE with web type": { - extraProps: map[string]interface{}{ - oktaPKCERequired: true, - oktaApplicationType: oktaAppTypeWeb, - }, - expectError: true, + extraProps: map[string]any{oktaPKCERequired: true, oktaApplicationType: oktaAppTypeWeb}, + wantErr: true, }, "Valid: No PKCE with any type": { - extraProps: map[string]interface{}{ - oktaApplicationType: oktaAppTypeService, - }, - expectError: false, + extraProps: map[string]any{oktaApplicationType: oktaAppTypeService}, }, } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := (&okta{logger: log.NewFieldLogger()}).validateExtraProperties(tc.extraProps) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} +func TestValidateOktaTemplates(t *testing.T) { + cases := map[string]struct { + appTemplate string + policyTemplate string + wantErr bool + errContains string + }{ + "default templates pass": { + appTemplate: defaultAppTemplate, + policyTemplate: defaultPolicyTemplate, + }, + "app template with only one recognized placeholder passes": { + appTemplate: corecfg.OktaPlaceholderMPApplicationName, + policyTemplate: defaultPolicyTemplate, + }, + "app template with two of three recognized placeholders passes": { + appTemplate: corecfg.OktaPlaceholderMPApplicationName + "-" + corecfg.OktaPlaceholderCredentialName, + policyTemplate: defaultPolicyTemplate, + }, + "unrecognized placeholder in app template fails": { + appTemplate: corecfg.OktaPlaceholderMPApplicationName + "-%TYPO%-" + corecfg.OktaPlaceholderCredentialName, + policyTemplate: defaultPolicyTemplate, + wantErr: true, + errContains: corecfg.OktaAppNameTemplateKey, + }, + "policy template missing %SCOPE% fails": { + appTemplate: defaultAppTemplate, + policyTemplate: corecfg.OktaPlaceholderOAuthFlow, + wantErr: true, + errContains: corecfg.OktaPlaceholderScope, + }, + "policy template missing %OAUTH_FLOW% fails": { + appTemplate: defaultAppTemplate, + policyTemplate: corecfg.OktaPlaceholderScope, + wantErr: true, + errContains: corecfg.OktaPlaceholderOAuthFlow, + }, + "unrecognized placeholder in policy template fails": { + appTemplate: defaultAppTemplate, + policyTemplate: corecfg.OktaPlaceholderScope + "-%CUSTOM_FLOW%", + wantErr: true, + errContains: corecfg.OktaPolicyNameTemplateKey, + }, + } for name, tc := range cases { t.Run(name, func(t *testing.T) { - oktaProvider := &okta{} - err := oktaProvider.validateExtraProperties(tc.extraProps) - if tc.expectError { + cfg := &corecfg.IDPConfiguration{ + Okta: &corecfg.OktaIDPConfiguration{ + AppNameTemplate: tc.appTemplate, + PolicyNameTemplate: tc.policyTemplate, + }, + } + err := validateOktaTemplates(cfg) + if tc.wantErr { assert.Error(t, err) - } else { - assert.NoError(t, err) + assert.Contains(t, err.Error(), tc.errContains) + return } + assert.NoError(t, err) }) } } func TestOktaPreProcessClientRequest(t *testing.T) { cases := map[string]struct { - grantTypes []string - responseTypes []string - extraProperties map[string]interface{} - expectedAppType string - expectedResponseTypes []string - expectedAuthMethod string + grantTypes []string + responseTypes []string + extraProperties map[string]any + wantAppType string + wantResponseTypes []string + wantAuthMethod string }{ "Authorization code with PKCE should use browser type": { - grantTypes: []string{GrantTypeAuthorizationCode}, - extraProperties: map[string]interface{}{ - oktaPKCERequired: true, - }, - expectedAppType: oktaAppTypeBrowser, - expectedAuthMethod: "none", + grantTypes: []string{GrantTypeAuthorizationCode}, + extraProperties: map[string]any{oktaPKCERequired: true}, + wantAppType: oktaAppTypeBrowser, + wantAuthMethod: testAuthMethodNone, }, "Authorization code without PKCE should use web type": { - grantTypes: []string{GrantTypeAuthorizationCode}, - extraProperties: map[string]interface{}{ - oktaPKCERequired: false, - }, - expectedAppType: oktaAppTypeWeb, + grantTypes: []string{GrantTypeAuthorizationCode}, + extraProperties: map[string]any{oktaPKCERequired: false}, + wantAppType: oktaAppTypeWeb, }, "Client credentials should remain service type": { - grantTypes: []string{GrantTypeClientCredentials}, - responseTypes: []string{}, - extraProperties: map[string]interface{}{}, - expectedAppType: oktaAppTypeService, - expectedResponseTypes: []string{AuthResponseToken}, + grantTypes: []string{GrantTypeClientCredentials}, + responseTypes: []string{}, + extraProperties: map[string]any{}, + wantAppType: oktaAppTypeService, + wantResponseTypes: []string{AuthResponseToken}, }, "Explicit browser type should be preserved with PKCE": { - grantTypes: []string{GrantTypeAuthorizationCode}, - extraProperties: map[string]interface{}{ - oktaApplicationType: oktaAppTypeBrowser, - oktaPKCERequired: true, - }, - expectedAppType: oktaAppTypeBrowser, - expectedAuthMethod: "none", + grantTypes: []string{GrantTypeAuthorizationCode}, + extraProperties: map[string]any{oktaApplicationType: oktaAppTypeBrowser, oktaPKCERequired: true}, + wantAppType: oktaAppTypeBrowser, + wantAuthMethod: testAuthMethodNone, }, "Implicit flow without PKCE should use web type": { - grantTypes: []string{GrantTypeImplicit}, - extraProperties: map[string]interface{}{ - oktaPKCERequired: false, - }, - expectedAppType: oktaAppTypeWeb, + grantTypes: []string{GrantTypeImplicit}, + extraProperties: map[string]any{oktaPKCERequired: false}, + wantAppType: oktaAppTypeWeb, }, } - for name, tc := range cases { t.Run(name, func(t *testing.T) { - oktaProvider := &okta{} + oktaProvider := &okta{logger: log.NewFieldLogger()} clientReq := &clientMetadata{ GrantTypes: tc.grantTypes, ResponseTypes: tc.responseTypes, extraProperties: tc.extraProperties, } - - // Simulate validation step which sets defaults (as happens in NewProvider) _ = oktaProvider.validateExtraProperties(clientReq.extraProperties) - oktaProvider.preProcessClientRequest(clientReq) appType, ok := clientReq.extraProperties[oktaApplicationType].(string) assert.True(t, ok) - assert.Equal(t, tc.expectedAppType, appType) + assert.Equal(t, tc.wantAppType, appType) - if tc.expectedResponseTypes != nil { - assert.Equal(t, tc.expectedResponseTypes, clientReq.ResponseTypes) + if tc.wantResponseTypes != nil { + assert.Equal(t, tc.wantResponseTypes, clientReq.ResponseTypes) } - - if tc.expectedAuthMethod != "" { - assert.Equal(t, tc.expectedAuthMethod, clientReq.TokenEndpointAuthMethod) + if tc.wantAuthMethod != "" { + assert.Equal(t, tc.wantAuthMethod, clientReq.TokenEndpointAuthMethod) } }) } } + +func TestOktaCreatePolicyUsesConfigPriority(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + var captured []byte + routes := map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + captured, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q}`, oktaPolicyID) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + } + ts, _ := newOktaScriptedServer(t, testAuthHeader, routes) + defer ts.Close() + + idpCfg := &corecfg.IDPConfiguration{ + MetadataURL: ts.URL + oauthMetadataEndpoint, + AuthConfig: &corecfg.IDPAuthConfiguration{AccessToken: accessToken}, + Okta: &corecfg.OktaIDPConfiguration{PolicyPriority: "5"}, + } + clientRes := &clientMetadata{ClientID: testClientID, Scope: []string{testScope}, GrantTypes: []string{GrantTypeClientCredentials}} + assert.NoError(t, oktaProvider.postProcessClientRegistration(clientRes, idpCfg, apiClient)) + + var body map[string]any + assert.NoError(t, json.Unmarshal(captured, &body)) + assert.EqualValues(t, testPolicyPriority, body["priority"]) +} + +func TestOktaDeactivateThenDelete(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + var mu sync.Mutex + callOrder := make([]string, 0, 2) + + routes := map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callOrder = append(callOrder, "deactivate") + mu.Unlock() + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callOrder = append(callOrder, "delete") + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }, + } + ts, _ := newOktaScriptedServer(t, testAuthHeader, routes) + defer ts.Close() + + err := oktaProvider.postProcessClientUnregister(testClientID, newIDPCredential(ts.URL), apiClient, nil, GrantTypeClientCredentials) + assert.NoError(t, err) + assert.Equal(t, []string{"deactivate", "delete"}, callOrder) +} + +func TestOktaPostProcessClientUnregisterAbortOnRemoveError(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + routes := map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{testClientID}), + http.MethodPut + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, + } + ts, scripted := newOktaScriptedServer(t, testAuthHeader, routes) + defer ts.Close() + + err := oktaProvider.postProcessClientUnregister(testClientID, newIDPCredential(ts.URL), apiClient, []string{testScope, "write:api"}, GrantTypeClientCredentials) + assert.Error(t, err) + + calls := scripted.getCalls() + assert.Equal(t, 1, calls[http.MethodGet+" "+oktaPoliciesEndpointByID], "policy list GET must stop after first scope fails") +} + +func TestPolicyNameTemplate(t *testing.T) { + cases := map[string]struct { + cfg corecfg.IDPConfig + grantType string + scope string + wantName string + }{ + "default template with client_credentials": { + cfg: &corecfg.IDPConfiguration{}, + grantType: GrantTypeClientCredentials, + scope: testScope, + wantName: testScope + "-" + normalizedClientCredentials, + }, + "default template with authorization_code": { + cfg: &corecfg.IDPConfiguration{}, + grantType: GrantTypeAuthorizationCode, + scope: testScope, + wantName: testScope + "-" + normalizedAuthorizationCode, + }, + "custom template overrides default": { + cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{PolicyNameTemplate: corecfg.OktaPlaceholderOAuthFlow + "-" + corecfg.OktaPlaceholderScope}}, + grantType: GrantTypeClientCredentials, + scope: testScope, + wantName: normalizedClientCredentials + "-" + testScope, + }, + "implicit flow normalizes correctly": { + cfg: &corecfg.IDPConfiguration{}, + grantType: GrantTypeImplicit, + scope: testScope, + wantName: testScope + "-" + normalizedImplicitGrant, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + template := policyNameTemplate(tc.cfg) + normalizedFlow := normalizeGrantType(tc.grantType) + got := strings.NewReplacer(corecfg.OktaPlaceholderScope, tc.scope, corecfg.OktaPlaceholderOAuthFlow, normalizedFlow).Replace(template) + assert.Equal(t, tc.wantName, got) + }) + } +} diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index f83bbfe2f..c4a5650e1 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -36,7 +36,7 @@ type Provider interface { GetSupportedTokenAuthMethods() []string GetSupportedResponseMethod() []string RegisterClient(clientMetadata ClientMetadata) (ClientMetadata, error) - UnregisterClient(clientID, accessToken, registrationClientURI string) error + UnregisterClient(clientID, accessToken, registrationClientURI string, scopes []string, grantType string) error Validate() error GetConfig() corecfg.IDPConfig GetMetadata() *AuthorizationServerMetadata @@ -62,7 +62,7 @@ type typedIDP interface { preProcessClientRequest(clientRequest *clientMetadata) validateExtraProperties(extraProps map[string]interface{}) error postProcessClientRegistration(clientRes ClientMetadata, idp corecfg.IDPConfig, apiClient coreapi.Client) error - postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client) error + postProcessClientUnregister(clientID string, idp corecfg.IDPConfig, apiClient coreapi.Client, scopes []string, grantType string) error } type providerOptions struct { @@ -96,7 +96,9 @@ func NewProvider(idp corecfg.IDPConfig, tlsCfg corecfg.TLSConfig, proxyURL strin var idpType typedIDP switch idp.GetIDPType() { case TypeOkta: - idpType = &okta{} + idpType = &okta{ + logger: log.NewFieldLogger().WithComponent("oktaProvider").WithPackage("sdk.agent.authz.oauth"), + } default: // keycloak, generic idpType = &genericIDP{} } @@ -130,13 +132,6 @@ func NewProvider(idp corecfg.IDPConfig, tlsCfg corecfg.TLSConfig, proxyURL strin p.authServerMetadata = metadata } - // Fail-fast Okta validation: if Okta group/policy is configured, verify the resources exist. - if idp.GetIDPType() == TypeOkta { - if err := validateOktaConfiguredResources(idp, apiClient); err != nil { - return nil, fmt.Errorf("failed to validate Okta configuration for provider %q: %w", p.cfg.GetIDPName(), err) - } - } - // No OAuth client is needed to request token for access token based authentication to IdP if p.cfg.GetAuthConfig() != nil && p.cfg.GetAuthConfig().GetType() != corecfg.AccessToken { authClient, err := p.createAuthClient() @@ -151,9 +146,51 @@ func NewProvider(idp corecfg.IDPConfig, tlsCfg corecfg.TLSConfig, proxyURL strin return nil, fmt.Errorf("invalid extra properties for %s provider: %w", idp.GetIDPType(), err) } + if idp.GetIDPType() == TypeOkta { + if err := validateOktaTemplates(idp); err != nil { + return nil, err + } + } + return p, nil } +func validateOktaTemplates(idp corecfg.IDPConfig) error { + appTmplCfg, ok := idp.(interface{ GetAppNameTemplate() string }) + if !ok { + return nil + } + appTmpl := appTmplCfg.GetAppNameTemplate() + test := strings.NewReplacer( + corecfg.OktaPlaceholderMPApplicationName, "x", + corecfg.OktaPlaceholderOwningTeam, "x", + corecfg.OktaPlaceholderCredentialName, "x", + ).Replace(appTmpl) + if strings.Contains(test, "%") { + return fmt.Errorf("%s contains unrecognized placeholder: %q", corecfg.OktaAppNameTemplateKey, appTmpl) + } + + polTmplCfg, ok := idp.(interface{ GetPolicyNameTemplate() string }) + if !ok { + return nil + } + polTmpl := polTmplCfg.GetPolicyNameTemplate() + if !strings.Contains(polTmpl, corecfg.OktaPlaceholderScope) { + return fmt.Errorf("%s missing required placeholder %s: %q", corecfg.OktaPolicyNameTemplateKey, corecfg.OktaPlaceholderScope, polTmpl) + } + if !strings.Contains(polTmpl, corecfg.OktaPlaceholderOAuthFlow) { + return fmt.Errorf("%s missing required placeholder %s: %q", corecfg.OktaPolicyNameTemplateKey, corecfg.OktaPlaceholderOAuthFlow, polTmpl) + } + test = strings.NewReplacer( + corecfg.OktaPlaceholderScope, "x", + corecfg.OktaPlaceholderOAuthFlow, "x", + ).Replace(polTmpl) + if strings.Contains(test, "%") { + return fmt.Errorf("%s contains unrecognized placeholder: %q", corecfg.OktaPolicyNameTemplateKey, polTmpl) + } + return nil +} + func FetchMetadata(apiClient coreapi.Client, metadataURL string) (*AuthorizationServerMetadata, error) { if apiClient == nil || metadataURL == "" { return nil, errors.New("unexpected arguments") @@ -547,7 +584,7 @@ func addResponseType(clientRequest *clientMetadata, responseType string) { } // UnregisterClient - removes the OAuth client from IDP -func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI string) error { +func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI string, scopes []string, grantType string) error { logger := p.logger. WithField(logProvider, p.cfg.GetIDPName()). WithField(logClientID, clientID) @@ -563,9 +600,8 @@ func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI accessToken = token } - // Run provider-specific cleanup first (for example, Okta group/policy unassignment), but always continue to OAuth client deletion so transient cleanup failures do not - // leave active clients behind. Return whichever error(s) occurred after both steps. - cleanupErr := p.runPostUnregisterHook(clientID) + // Continue to OAuth client deletion even on cleanup error; leave no active clients behind. + cleanupErr := p.runPostUnregisterHook(clientID, scopes, grantType) unregisterErr := p.attemptUnregisterAll(logger, clientID, registrationClientURI, authPrefix, accessToken) if unregisterErr == nil { @@ -584,12 +620,11 @@ func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI } } -// runPostUnregisterHook calls provider-specific unregister hook when applicable. -func (p *provider) runPostUnregisterHook(clientID string) error { +func (p *provider) runPostUnregisterHook(clientID string, scopes []string, grantType string) error { if p.cfg.GetIDPType() != TypeOkta { return nil } - return p.idpType.postProcessClientUnregister(clientID, p.cfg, p.apiClient) + return p.idpType.postProcessClientUnregister(clientID, p.cfg, p.apiClient, scopes, grantType) } // attemptUnregisterAll tries unregistering with the registration URI, the standard @@ -649,7 +684,7 @@ func (p *provider) tryUnregister(unregisterURL, clientID, authPrefix, accessToke for k, v := range p.queryParameters { queryParams[k] = v } - queryParams["client_id"] = clientID + queryParams[metaClientID] = clientID } else { queryParams = p.queryParameters } diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 3bf5e0a57..902eeca1d 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -247,7 +247,7 @@ func runProviderTestCase(t *testing.T, tc providerTestCase) { assert.Equal(t, s.GetUnregisterEndpoint(), cr.GetRegistrationClientURI()) } s.SetRegistrationResponseCode(tc.unRegistrationResponseCode) - err = p.UnregisterClient(cr.GetClientID(), cr.GetRegistrationAccessToken(), s.GetUnregisterEndpoint()) + err = p.UnregisterClient(cr.GetClientID(), cr.GetRegistrationAccessToken(), s.GetUnregisterEndpoint(), cr.GetScopes(), "") if tc.expectUnRegistrationErr { assert.NotNil(t, err) return @@ -324,90 +324,6 @@ func TestNewProviderValidatesExtraProperties(t *testing.T) { } } -func TestNewProviderOktaValidatesConfiguredGroupAndPolicyExist(t *testing.T) { - token := testToken - var srv *httptest.Server - srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == testAuthServerMetadataURL: - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"issuer":"` + srv.URL + `","token_endpoint":"` + srv.URL + `/token","registration_endpoint":"` + srv.URL + `/register","authorization_endpoint":"` + srv.URL + `/auth"}`)) - return - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/groups": - if r.URL.Query().Get("q") == "Marketplace" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[{"id":"00g-123","profile":{"name":"Marketplace"}}]`)) - return - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[]`)) - return - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/authorizationServers/authorizationID/policies": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[{"id":"pol-123","name":"shane"}]`)) - return - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/authorizationServers/authorizationID/policies/pol-123": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"id":"pol-123","name":"shane","conditions":{"clients":{"include":[]}}}`)) - return - default: - w.WriteHeader(http.StatusNotFound) - return - } - })) - defer srv.Close() - - idpCfg := &config.IDPConfiguration{ - Name: "test", - Type: TypeOkta, - MetadataURL: srv.URL + testAuthServerMetadataURL, - Okta: &config.OktaIDPConfiguration{Group: "Marketplace", Policy: "shane"}, - AuthConfig: &config.IDPAuthConfiguration{ - Type: config.AccessToken, - AccessToken: token, - }, - } - - p, err := NewProvider(idpCfg, config.NewTLSConfig(), "", 10*time.Second) - assert.NoError(t, err) - assert.NotNil(t, p) -} - -func TestNewProviderOktaFailsFastWhenConfiguredGroupMissing(t *testing.T) { - token := testToken - var srv *httptest.Server - srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == testAuthServerMetadataURL { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"issuer":"` + srv.URL + `","token_endpoint":"` + srv.URL + `/token","registration_endpoint":"` + srv.URL + `/register","authorization_endpoint":"` + srv.URL + `/auth"}`)) - return - } - if r.Method == http.MethodGet && r.URL.Path == "/api/v1/groups" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[]`)) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - idpCfg := &config.IDPConfiguration{ - Name: "test", - Type: TypeOkta, - MetadataURL: srv.URL + testAuthServerMetadataURL, - Okta: &config.OktaIDPConfiguration{Group: "Marketplace"}, - AuthConfig: &config.IDPAuthConfiguration{ - Type: config.AccessToken, - AccessToken: token, - }, - } - - p, err := NewProvider(idpCfg, config.NewTLSConfig(), "", 10*time.Second) - assert.Error(t, err) - assert.Nil(t, p) - assert.Contains(t, err.Error(), "configured okta group") -} - type failingHookIDP struct { authPrefix string regErr error @@ -433,7 +349,7 @@ func (f *failingHookIDP) postProcessClientRegistration(clientRes ClientMetadata, return f.regErr } -func (f *failingHookIDP) postProcessClientUnregister(clientID string, idp config.IDPConfig, apiClient coreapi.Client) error { +func (f *failingHookIDP) postProcessClientUnregister(clientID string, idp config.IDPConfig, apiClient coreapi.Client, scopes []string, grantType string) error { return f.unregErr } @@ -546,7 +462,7 @@ func TestUnregisterClientDeleteHookFails(t *testing.T) { idpCfg.Type = TypeOkta p.idpType = &failingHookIDP{unregErr: errors.New("cleanup failed")} - err = p.UnregisterClient("cid-1", testToken, srv.URL+testRegisterURL) + err = p.UnregisterClient("cid-1", testToken, srv.URL+testRegisterURL, nil, "") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to complete provider cleanup after client unregistration") @@ -589,7 +505,7 @@ func TestUnregisterClientCleanupAndDeleteFail(t *testing.T) { idpCfg.Type = TypeOkta p.idpType = &failingHookIDP{unregErr: errors.New("cleanup failed")} - err = p.UnregisterClient("cid-1", testToken, srv.URL+testRegisterURL) + err = p.UnregisterClient("cid-1", testToken, srv.URL+testRegisterURL, nil, "") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to fully remove the Okta client") diff --git a/pkg/config/externalidpconfig.go b/pkg/config/externalidpconfig.go index 0f6b0fb04..8300cea74 100644 --- a/pkg/config/externalidpconfig.go +++ b/pkg/config/externalidpconfig.go @@ -19,6 +19,14 @@ const ( TLSClientAuth = "tls_client_auth" SelfSignedTLSClientAuth = "self_signed_tls_client_auth" + OktaPlaceholderMPApplicationName = "%MP_APPLICATION_NAME%" + OktaPlaceholderOwningTeam = "%OWNING_TEAM%" + OktaPlaceholderCredentialName = "%CREDENTIAL_NAME%" + OktaPlaceholderScope = "%SCOPE%" + OktaPlaceholderOAuthFlow = "%OAUTH_FLOW%" + OktaAppNameTemplateKey = "AGENTFEATURES_IDP_OKTA_APPNAME_TEMPLATE" + OktaPolicyNameTemplateKey = "AGENTFEATURES_IDP_OKTA_POLICYNAME_TEMPLATE" + propInsecureSkipVerify = "insecureSkipVerify" propUseCachedToken = "useCachedToken" propUseRegistrationToken = "useRegistrationToken" @@ -27,8 +35,12 @@ const ( fldTitle = "title" fldType = "type" fldMetadataURL = "metadataUrl" - fldOktaGroup = "okta.group" - fldOktaPolicy = "okta.policy" + fldOktaTokenLifetimeMin = "okta.token_lifetime_min" + fldOktaAppNameTemplate = "okta.appname_template" + fldOktaPolicyNameTemplate = "okta.policyname_template" + fldOktaPolicyPriority = "okta.policy_priority" + fldOktaScopeSources = "okta.scope_sources" + fldOktaScopeBlacklist = "okta.scope_blacklist" fldExtraProperties = "extraProperties" fldRequestHeaders = "requestHeaders" fldQueryParams = "queryParams" @@ -53,6 +65,13 @@ const ( fldSSLRootCACertPath = "ssl.rootCACertPath" fldSSLClientCertPath = "ssl.clientCertPath" fldSSLClientKeyPath = "ssl.clientKeyPath" + + defaultOktaAppNameTemplate = OktaPlaceholderMPApplicationName + "-" + OktaPlaceholderOwningTeam + "-" + OktaPlaceholderCredentialName + defaultOktaPolicyNameTemplate = OktaPlaceholderScope + "-" + OktaPlaceholderOAuthFlow + defaultOktaPolicyPriority = "1" + defaultOktaTokenLifetimeMin = "60" + defaultOktaScopeSources = "swagger,gateway,okta" + defaultOktaScopeBlacklist = "openid,profile,email,address,phone,offline_access" ) var configProperties = []string{ @@ -60,8 +79,12 @@ var configProperties = []string{ fldTitle, fldType, fldMetadataURL, - fldOktaGroup, - fldOktaPolicy, + fldOktaTokenLifetimeMin, + fldOktaAppNameTemplate, + fldOktaPolicyNameTemplate, + fldOktaPolicyPriority, + fldOktaScopeSources, + fldOktaScopeBlacklist, fldExtraProperties, fldRequestHeaders, fldQueryParams, @@ -236,10 +259,6 @@ type IDPConfig interface { GetIDPTitle() string // GetAuthConfig - to be used for authentication with IDP GetAuthConfig() IDPAuthConfig - // GetOktaGroup - okta-specific group assignment configuration. - GetOktaGroup() string - // GetOktaPolicy - okta-specific authorization server policy name to look up. - GetOktaPolicy() string // GetClientScopes - default list of scopes that are included in the client metadata request to IDP GetClientScopes() string // GetGrantType - default grant type to be used when creating the client. (default : "client_credentials") @@ -279,8 +298,12 @@ type IDPAuthConfiguration struct { // OktaIDPConfiguration - okta-specific configuration. type OktaIDPConfiguration struct { - Group string `json:"group,omitempty"` - Policy string `json:"policy,omitempty"` + TokenLifetimeMin string `json:"token_lifetime_min,omitempty"` + AppNameTemplate string `json:"appname_template,omitempty"` + PolicyNameTemplate string `json:"policyname_template,omitempty"` + PolicyPriority string `json:"policy_priority,omitempty"` + ScopeSources string `json:"scope_sources,omitempty"` + ScopeBlacklist string `json:"scope_blacklist,omitempty"` } // IDPConfiguration - Structure to hold the IdP provider config @@ -306,7 +329,7 @@ func (i *IDPConfiguration) GetIDPName() string { return i.Name } -// GetIDPName - for the identity provider frinedly name +// GetIDPTitle - for the identity provider friendly name func (i *IDPConfiguration) GetIDPTitle() string { return i.Title } @@ -326,20 +349,60 @@ func (i *IDPConfiguration) GetMetadataURL() string { return i.MetadataURL } -// GetOktaGroup - returns Okta group name configured for this IDP (if any). -func (i *IDPConfiguration) GetOktaGroup() string { - if i.Okta == nil { - return "" +// GetTokenLifetimeMinutes - access token lifetime minutes for new Okta policy rules; defaults to 60. +func (i *IDPConfiguration) GetTokenLifetimeMinutes() int { + if i.Okta == nil || i.Okta.TokenLifetimeMin == "" { + return 60 + } + v, err := strconv.Atoi(i.Okta.TokenLifetimeMin) + if err != nil || v <= 0 { + return 60 + } + return v +} + +// GetAppNameTemplate - Okta application name template; defaults to MP_APPLICATION_NAME-OWNING_TEAM-CREDENTIAL_NAME pattern. +func (i *IDPConfiguration) GetAppNameTemplate() string { + if i.Okta == nil || i.Okta.AppNameTemplate == "" { + return defaultOktaAppNameTemplate + } + return i.Okta.AppNameTemplate +} + +// GetPolicyNameTemplate - Okta access policy name template; defaults to SCOPE-OAUTH_FLOW pattern. +func (i *IDPConfiguration) GetPolicyNameTemplate() string { + if i.Okta == nil || i.Okta.PolicyNameTemplate == "" { + return defaultOktaPolicyNameTemplate + } + return i.Okta.PolicyNameTemplate +} + +// GetPolicyPriority - priority for new Okta authorization server policies; defaults to 1. +func (i *IDPConfiguration) GetPolicyPriority() int { + if i.Okta == nil || i.Okta.PolicyPriority == "" { + return 1 + } + v, err := strconv.Atoi(i.Okta.PolicyPriority) + if err != nil || v <= 0 { + return 1 + } + return v +} + +// GetScopeSources - comma-separated active scope sources (swagger, gateway, okta); consumed by the v7 agent. +func (i *IDPConfiguration) GetScopeSources() string { + if i.Okta == nil || i.Okta.ScopeSources == "" { + return defaultOktaScopeSources } - return i.Okta.Group + return i.Okta.ScopeSources } -// GetOktaPolicy - returns Okta authorization server policy name for this IDP (if any). -func (i *IDPConfiguration) GetOktaPolicy() string { - if i.Okta == nil { - return "" +// GetScopeBlacklist - comma-separated scopes excluded from the Marketplace UI; consumed by the v7 agent. +func (i *IDPConfiguration) GetScopeBlacklist() string { + if i.Okta == nil || i.Okta.ScopeBlacklist == "" { + return defaultOktaScopeBlacklist } - return i.Okta.Policy + return i.Okta.ScopeBlacklist } // GetExtraProperties - set of additional properties to be applied when registering the client @@ -654,7 +717,7 @@ func ParseExternalIDPConfig(agentFeature AgentFeaturesConfig, props properties.P QueryParams: make(IDPQueryParams), ClientScopes: "resource.READ resource.WRITE", GrantType: "client_credentials", - AuthMethod: "client_secret_basic", + AuthMethod: ClientSecretBasic, AuthResponseType: "token", } diff --git a/pkg/config/externalidpconfig_test.go b/pkg/config/externalidpconfig_test.go index bd09d3514..f950399fb 100644 --- a/pkg/config/externalidpconfig_test.go +++ b/pkg/config/externalidpconfig_test.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "os" + "reflect" "testing" "github.com/Axway/agent-sdk/pkg/cmd/properties" @@ -10,6 +11,31 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + testIDPName = "test" + testMetaURL = "test" + testAccessTok = "accessToken" + testIDPTypeOkta = "okta" + + envKeyIDPName = "AGENTFEATURES_IDP_NAME_1" + envKeyIDPType = "AGENTFEATURES_IDP_TYPE_1" + envKeyIDPMetadataURL = "AGENTFEATURES_IDP_METADATAURL_1" + envKeyIDPRequestHeaders = "AGENTFEATURES_IDP_REQUESTHEADERS_1" + envKeyIDPQueryParams = "AGENTFEATURES_IDP_QUERYPARAMS_1" + envKeyIDPAuthType = "AGENTFEATURES_IDP_AUTH_TYPE_1" + envKeyIDPAuthAccessToken = "AGENTFEATURES_IDP_AUTH_ACCESSTOKEN_1" + envKeyIDPAuthClientID = "AGENTFEATURES_IDP_AUTH_CLIENTID_1" + envKeyIDPAuthClientSecret = "AGENTFEATURES_IDP_AUTH_CLIENTSECRET_1" + envKeyIDPAuthRequestHeaders = "AGENTFEATURES_IDP_AUTH_REQUESTHEADERS_1" + envKeyIDPAuthQueryParams = "AGENTFEATURES_IDP_AUTH_QUERYPARAMS_1" + envKeyIDPOktaTokenLifetimeMin = "AGENTFEATURES_IDP_OKTA_TOKEN_LIFETIME_MIN_1" + envKeyIDPOktaAppNameTemplate = "AGENTFEATURES_IDP_OKTA_APPNAME_TEMPLATE_1" + envKeyIDPOktaPolicyNameTemplate = "AGENTFEATURES_IDP_OKTA_POLICYNAME_TEMPLATE_1" + envKeyIDPOktaPolicyPriority = "AGENTFEATURES_IDP_OKTA_POLICY_PRIORITY_1" + envKeyIDPOktaScopeSources = "AGENTFEATURES_IDP_OKTA_SCOPE_SOURCES_1" + envKeyIDPOktaScopeBlacklist = "AGENTFEATURES_IDP_OKTA_SCOPE_BLACKLIST_1" +) + func setEnvVars(t *testing.T, env map[string]string) { t.Helper() for key, val := range env { @@ -32,7 +58,7 @@ func parseExternalIDP(t *testing.T) ExternalIDPConfig { return agentFeatures.ExternalIDPConfig } -func assertIDPRoundTrip(t *testing.T, idp IDPConfig, expectedOktaGroup string, expectedOktaPolicy string) { +func assertIDPRoundTrip(t *testing.T, idp IDPConfig) { t.Helper() buf, err := json.Marshal(idp) assert.NoError(t, err) @@ -52,23 +78,11 @@ func assertIDPRoundTrip(t *testing.T, idp IDPConfig, expectedOktaGroup string, e assert.Equal(t, idp.GetAuthConfig().GetClientSecret(), parsedIDP.GetAuthConfig().GetClientSecret()) assert.Equal(t, len(idp.GetAuthConfig().GetRequestHeaders()), len(parsedIDP.GetAuthConfig().GetRequestHeaders())) assert.Equal(t, len(idp.GetAuthConfig().GetQueryParams()), len(parsedIDP.GetAuthConfig().GetQueryParams())) - - if expectedOktaGroup != "" { - assert.Equal(t, expectedOktaGroup, idp.GetOktaGroup()) - assert.Equal(t, expectedOktaGroup, parsedIDP.GetOktaGroup()) - } - if expectedOktaPolicy != "" { - assert.Equal(t, expectedOktaPolicy, idp.GetOktaPolicy()) - assert.Equal(t, expectedOktaPolicy, parsedIDP.GetOktaPolicy()) - } } type externalIDPTestCase struct { - name string - envNames map[string]string - oktaGroup string - oktaPolicy string - hasError bool + envNames map[string]string + hasError bool } func runExternalIDPTestCase(t *testing.T, tc externalIDPTestCase) { @@ -83,132 +97,194 @@ func runExternalIDPTestCase(t *testing.T, tc externalIDPTestCase) { } assert.NoError(t, err) for _, idp := range idpCfgs.GetIDPList() { - assertIDPRoundTrip(t, idp, tc.oktaGroup, tc.oktaPolicy) + assertIDPRoundTrip(t, idp) + } +} + +func mergeEnv(base, extra map[string]string) map[string]string { + out := make(map[string]string, len(base)+len(extra)) + for k, v := range base { + out[k] = v + } + for k, v := range extra { + out[k] = v } + return out } func TestExternalIDPConfig(t *testing.T) { - testCases := []externalIDPTestCase{ - { - name: "no external IDP config", - envNames: map[string]string{}, - hasError: false, - }, - { - name: "no name in IDP config", - envNames: map[string]string{ - "AGENTFEATURES_IDP_METADATAURL_1": "test", - }, + baseOkta := map[string]string{ + envKeyIDPName: testIDPName, + envKeyIDPType: testIDPTypeOkta, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: AccessToken, + envKeyIDPAuthAccessToken: testAccessTok, + } + + cases := map[string]externalIDPTestCase{ + "no external IDP config": {envNames: map[string]string{}}, + "no name in IDP config": { + envNames: map[string]string{envKeyIDPMetadataURL: testMetaURL}, hasError: true, }, - { - name: "no metadata URL in IDP config", - envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - }, + "no metadata URL in IDP config": { + envNames: map[string]string{envKeyIDPName: testIDPName}, hasError: true, }, - { - name: "no auth config in IDP config", + "no auth config in IDP config": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, }, hasError: true, }, - { - name: "invalid IDP auth type config in IDP config", + "invalid IDP auth type config": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "invalid", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: "invalid", }, hasError: true, }, - { - name: "accessToken auth config with no token in IDP config", + "accessToken auth config with no token": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "accessToken", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: AccessToken, }, hasError: true, }, - { - name: "accessToken auth config with valid token in IDP config", + "accessToken auth config with valid token": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "accessToken", - "AGENTFEATURES_IDP_AUTH_ACCESSTOKEN_1": "accessToken", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: AccessToken, + envKeyIDPAuthAccessToken: testAccessTok, }, - hasError: false, }, - { - name: "okta group config via env var", - envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_TYPE_1": "okta", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "accessToken", - "AGENTFEATURES_IDP_AUTH_ACCESSTOKEN_1": "accessToken", - "AGENTFEATURES_IDP_OKTA_GROUP_1": "MyAppUsers", - }, - oktaGroup: "MyAppUsers", - hasError: false, + "okta token lifetime min config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaTokenLifetimeMin: "120"}), }, - { - name: "okta policy config via env var", - envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_TYPE_1": "okta", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "accessToken", - "AGENTFEATURES_IDP_AUTH_ACCESSTOKEN_1": "accessToken", - "AGENTFEATURES_IDP_OKTA_POLICY_1": "marketplacePolicy", - }, - oktaPolicy: "marketplacePolicy", - hasError: false, + "okta appname template config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaAppNameTemplate: OktaPlaceholderMPApplicationName}), + }, + "okta policy name template config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaPolicyNameTemplate: OktaPlaceholderScope}), + }, + "okta policy priority config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaPolicyPriority: "3"}), + }, + "okta scope sources config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeSources: "swagger,okta"}), + }, + "okta scope blacklist config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeBlacklist: "openid,profile"}), }, - { - name: "client auth config with no clientid/secret in IDP config", + "client auth config with no clientid/secret": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "client", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: Client, }, hasError: true, }, - { - name: "client auth config with no client secret in IDP config", + "client auth config with no client secret": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "client", - "AGENTFEATURES_IDP_AUTH_CLIENTID_1": "client-id", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPAuthType: Client, + envKeyIDPAuthClientID: "client-id", }, hasError: true, }, - { - name: "client auth config with valid client config in IDP config", + "client auth config with valid client config": { envNames: map[string]string{ - "AGENTFEATURES_IDP_NAME_1": "test", - "AGENTFEATURES_IDP_METADATAURL_1": "test", - "AGENTFEATURES_IDP_REQUESTHEADERS_1": "{\"hdr\":\"value\"}", - "AGENTFEATURES_IDP_QUERYPARAMS_1": "{\"param\":\"value\"}", - "AGENTFEATURES_IDP_AUTH_TYPE_1": "client", - "AGENTFEATURES_IDP_AUTH_CLIENTID_1": "client-id", - "AGENTFEATURES_IDP_AUTH_CLIENTSECRET_1": "client-secret", - "AGENTFEATURES_IDP_AUTH_REQUESTHEADERS_1": "{\"authhdr\":\"value\"}", - "AGENTFEATURES_IDP_AUTH_QUERYPARAMS_1": "{\"authparam\":\"value\"}", + envKeyIDPName: testIDPName, + envKeyIDPMetadataURL: testMetaURL, + envKeyIDPRequestHeaders: `{"hdr":"value"}`, + envKeyIDPQueryParams: `{"param":"value"}`, + envKeyIDPAuthType: Client, + envKeyIDPAuthClientID: "client-id", + envKeyIDPAuthClientSecret: "client-secret", + envKeyIDPAuthRequestHeaders: `{"authhdr":"value"}`, + envKeyIDPAuthQueryParams: `{"authparam":"value"}`, }, - hasError: false, }, } - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { + for name, tc := range cases { + t.Run(name, func(t *testing.T) { runExternalIDPTestCase(t, tc) }) } } + +func TestOktaIDPConfigGetters(t *testing.T) { + cases := map[string]struct { + cfg *IDPConfiguration + wantTokenLifetime int + wantPolicyPriority int + wantAppTemplate string + wantPolicyTemplate string + wantScopeSources string + wantScopeBlacklist string + }{ + "nil okta config returns all defaults": { + cfg: &IDPConfiguration{}, + wantTokenLifetime: 60, + wantPolicyPriority: 1, + wantAppTemplate: defaultOktaAppNameTemplate, + wantPolicyTemplate: defaultOktaPolicyNameTemplate, + wantScopeSources: defaultOktaScopeSources, + wantScopeBlacklist: defaultOktaScopeBlacklist, + }, + "configured values are returned": { + cfg: &IDPConfiguration{Okta: &OktaIDPConfiguration{ + TokenLifetimeMin: "90", + PolicyPriority: "5", + AppNameTemplate: "my-" + OktaPlaceholderCredentialName, + PolicyNameTemplate: OktaPlaceholderScope, + ScopeSources: "swagger", + ScopeBlacklist: "openid", + }}, + wantTokenLifetime: 90, + wantPolicyPriority: 5, + wantAppTemplate: "my-" + OktaPlaceholderCredentialName, + wantPolicyTemplate: OktaPlaceholderScope, + wantScopeSources: "swagger", + wantScopeBlacklist: "openid", + }, + "invalid int values fall back to defaults": { + cfg: &IDPConfiguration{Okta: &OktaIDPConfiguration{TokenLifetimeMin: "bad", PolicyPriority: "bad"}}, + wantTokenLifetime: 60, + wantPolicyPriority: 1, + wantAppTemplate: defaultOktaAppNameTemplate, + wantPolicyTemplate: defaultOktaPolicyNameTemplate, + wantScopeSources: defaultOktaScopeSources, + wantScopeBlacklist: defaultOktaScopeBlacklist, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.wantTokenLifetime, tc.cfg.GetTokenLifetimeMinutes()) + assert.Equal(t, tc.wantPolicyPriority, tc.cfg.GetPolicyPriority()) + assert.Equal(t, tc.wantAppTemplate, tc.cfg.GetAppNameTemplate()) + assert.Equal(t, tc.wantPolicyTemplate, tc.cfg.GetPolicyNameTemplate()) + assert.Equal(t, tc.wantScopeSources, tc.cfg.GetScopeSources()) + assert.Equal(t, tc.wantScopeBlacklist, tc.cfg.GetScopeBlacklist()) + }) + } +} + +func TestRemovedSymbols(t *testing.T) { + typ := reflect.TypeFor[OktaIDPConfiguration]() + cases := map[string]struct{}{ + "Group": {}, + "Policy": {}, + } + for field := range cases { + t.Run(field, func(t *testing.T) { + _, found := typ.FieldByName(field) + assert.False(t, found, "field %q must not exist on OktaIDPConfiguration", field) + }) + } +} From ffc6843dff21828e900888e2be5a65403ae2940b Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 10:00:50 -0700 Subject: [PATCH 02/22] APIGOV-32943 - remove policy token and priority configs according to engie --- pkg/authz/oauth/clients/okta.go | 8 ++--- pkg/authz/oauth/clients/okta_test.go | 24 ++++++------- pkg/authz/oauth/oktaprovider.go | 26 ++------------ pkg/authz/oauth/oktaprovider_test.go | 54 ++-------------------------- pkg/config/externalidpconfig.go | 32 ----------------- pkg/config/externalidpconfig_test.go | 27 -------------- 6 files changed, 19 insertions(+), 152 deletions(-) diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 04f8fd05e..4f06ed05f 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -243,7 +243,7 @@ func (o *Okta) AssignClientToPolicy(authServerID string, policy map[string]inter return o.UpdatePolicy(authServerID, policyID, policy) } -func (o *Okta) CreatePolicy(authServerID, name string, priority int, clientID string) (map[string]interface{}, error) { +func (o *Okta) CreatePolicy(authServerID, name string, clientID string) (map[string]interface{}, error) { endpoint := o.authServerPoliciesEndpoint(authServerID) o.logger. WithField("authServerID", authServerID). @@ -254,7 +254,7 @@ func (o *Okta) CreatePolicy(authServerID, name string, priority int, clientID st Name: name, Type: "OAUTH_AUTHORIZATION_POLICY", Status: "ACTIVE", - Priority: priority, + Priority: 1, //SDB - check to see if it defaults to 1 Conditions: oktaPolicyConditions{ Clients: &oktaPolicyConditionsClients{Include: []string{clientID}}, }, @@ -273,7 +273,7 @@ func (o *Okta) CreatePolicy(authServerID, name string, priority int, clientID st return policy, nil } -func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope string, tokenLifetimeMinutes int) error { +func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope string) error { endpoint := fmt.Sprintf("%s/api/v1/authorizationServers/%s/policies/%s/rules", o.BaseURL, authServerID, policyID) o.logger. WithField("authServerID", authServerID). @@ -290,7 +290,7 @@ func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope s Scopes: oktaPolicyRuleConditionScopes{Include: []string{scope}}, }, Actions: oktaPolicyRuleActions{ - Token: oktaPolicyRuleActionToken{AccessTokenLifetimeMinutes: tokenLifetimeMinutes}, + Token: oktaPolicyRuleActionToken{AccessTokenLifetimeMinutes: 60}, // SDB - see if this defaults to 60 }, } resp, err := o.doRequest(coreapi.POST, endpoint, req) diff --git a/pkg/authz/oauth/clients/okta_test.go b/pkg/authz/oauth/clients/okta_test.go index 61e8e1222..6bf70a031 100644 --- a/pkg/authz/oauth/clients/okta_test.go +++ b/pkg/authz/oauth/clients/okta_test.go @@ -16,11 +16,9 @@ const ( caseErrForbidden = "returns error on forbidden" testPolicyName = "my-policy" testClientID = "client1" - testPriority = 2 testRuleName = "rule-1" testGrantType = "client_credentials" testRuleScope = "read:api" - testLifetime = 90 ) func newTestOktaClient(t *testing.T, handler http.HandlerFunc) (*Okta, func()) { @@ -68,7 +66,7 @@ func TestOktaCreatePolicy(t *testing.T) { wantErr bool }{ "returns created policy on 201": {code: http.StatusCreated, body: `{"id":"pol-new","name":"my-policy"}`, wantErr: false}, - caseErrForbidden: {code: http.StatusForbidden, body: `forbidden`, wantErr: true}, + caseErrForbidden: {code: http.StatusForbidden, body: `forbidden`, wantErr: true}, } for name, tc := range cases { tc := tc @@ -78,7 +76,7 @@ func TestOktaCreatePolicy(t *testing.T) { _, _ = w.Write([]byte(tc.body)) }) defer close() - _, err := client.CreatePolicy("as1", testPolicyName, 1, testClientID) + _, err := client.CreatePolicy("as1", testPolicyName, testClientID) assertOktaErr(t, err, tc.wantErr) }) } @@ -92,14 +90,14 @@ func TestOktaCreatePolicyRequestBody(t *testing.T) { _, _ = w.Write([]byte(`{"id":"pol1"}`)) }) defer teardown() - _, err := client.CreatePolicy("as1", testPolicyName, testPriority, testClientID) + _, err := client.CreatePolicy("as1", testPolicyName, testClientID) assertOktaErr(t, err, false) var body map[string]interface{} assert.NoError(t, json.Unmarshal(captured, &body)) assert.Equal(t, "OAUTH_AUTHORIZATION_POLICY", body["type"]) assert.Equal(t, "ACTIVE", body["status"]) - assert.EqualValues(t, testPriority, body["priority"]) + assert.EqualValues(t, 1, body["priority"]) conditions, _ := body["conditions"].(map[string]interface{}) clients, _ := conditions["clients"].(map[string]interface{}) @@ -126,7 +124,7 @@ func TestOktaCreatePolicyRule(t *testing.T) { w.WriteHeader(tc.code) }) defer close() - err := client.CreatePolicyRule("as1", "pol1", "rule", testGrantType, testRuleScope, 60) + err := client.CreatePolicyRule("as1", "pol1", "rule", testGrantType, testRuleScope) assertOktaErr(t, err, tc.wantErr) }) } @@ -139,7 +137,7 @@ func TestOktaCreatePolicyRuleRequestBody(t *testing.T) { w.WriteHeader(http.StatusCreated) }) defer teardown() - err := client.CreatePolicyRule("as1", "pol1", testRuleName, testGrantType, testRuleScope, testLifetime) + err := client.CreatePolicyRule("as1", "pol1", testRuleName, testGrantType, testRuleScope) assertOktaErr(t, err, false) var body map[string]interface{} @@ -162,7 +160,7 @@ func TestOktaCreatePolicyRuleRequestBody(t *testing.T) { actions, _ := body["actions"].(map[string]interface{}) token, _ := actions["token"].(map[string]interface{}) - assert.EqualValues(t, testLifetime, token["accessTokenLifetimeMinutes"]) + assert.EqualValues(t, 60, token["accessTokenLifetimeMinutes"]) } func TestOktaRemoveClientFromPolicy(t *testing.T) { @@ -238,10 +236,10 @@ func TestOktaDeactivateApp(t *testing.T) { code int wantErr bool }{ - "returns nil on 200": {code: http.StatusOK, wantErr: false}, - "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, - "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, - caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + "returns nil on 200": {code: http.StatusOK, wantErr: false}, + "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, + "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, + caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, } for name, tc := range cases { tc := tc diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index d4aa55805..a284f563c 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -73,28 +73,6 @@ func normalizeGrantType(grantType string) string { } } -func (i *okta) tokenLifetimeMinutes(idp corecfg.IDPConfig) int { - cfg, ok := idp.(interface{ GetTokenLifetimeMinutes() int }) - if !ok { - return 60 - } - if v := cfg.GetTokenLifetimeMinutes(); v > 0 { - return v - } - return 60 -} - -func (i *okta) policyPriority(idp corecfg.IDPConfig) int { - cfg, ok := idp.(interface{ GetPolicyPriority() int }) - if !ok { - return 1 - } - if v := cfg.GetPolicyPriority(); v > 0 { - return v - } - return 1 -} - func policyNameTemplate(idp corecfg.IDPConfig) string { cfg, ok := idp.(interface{ GetPolicyNameTemplate() string }) if !ok { @@ -228,12 +206,12 @@ func (i *okta) assignScopePolicy( if policy == nil { i.logger.WithField("policyName", policyName).Trace("policy not found, creating") - createdPolicy, err := oktaClient.CreatePolicy(authServerID, policyName, i.policyPriority(idp), clientRes.GetClientID()) + createdPolicy, err := oktaClient.CreatePolicy(authServerID, policyName, clientRes.GetClientID()) if err != nil { return err } policyID, _ := createdPolicy["id"].(string) - if err := oktaClient.CreatePolicyRule(authServerID, policyID, policyName, grantType, scope, i.tokenLifetimeMinutes(idp)); err != nil { + if err := oktaClient.CreatePolicyRule(authServerID, policyID, policyName, grantType, scope); err != nil { return err } i.logger.WithField("policyName", policyName).Trace("created policy and rule") diff --git a/pkg/authz/oauth/oktaprovider_test.go b/pkg/authz/oauth/oktaprovider_test.go index 3936e9086..97b40d184 100644 --- a/pkg/authz/oauth/oktaprovider_test.go +++ b/pkg/authz/oauth/oktaprovider_test.go @@ -22,7 +22,6 @@ const ( testClientID = "app123" accessToken = "access-token" testAuthHeader = "SSWS " + accessToken - testPolicyPriority = 5 defaultAppTemplate = corecfg.OktaPlaceholderMPApplicationName + "-" + corecfg.OktaPlaceholderOwningTeam + "-" + corecfg.OktaPlaceholderCredentialName defaultPolicyTemplate = corecfg.OktaPlaceholderScope + "-" + corecfg.OktaPlaceholderOAuthFlow normalizedClientCredentials = "clientcredentials" @@ -368,54 +367,6 @@ func TestNormalizeGrantType(t *testing.T) { } } -func TestOktaTokenLifetimeMinutes(t *testing.T) { - o := &okta{logger: log.NewFieldLogger()} - cases := map[string]struct { - cfg corecfg.IDPConfig - want int - }{ - "returns configured value": { - cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{TokenLifetimeMin: "90"}}, - want: 90, - }, - "returns default 60 when nil okta cfg": { - cfg: &corecfg.IDPConfiguration{}, - want: 60, - }, - "returns default 60 when zero value": { - cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{TokenLifetimeMin: "0"}}, - want: 60, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.want, o.tokenLifetimeMinutes(tc.cfg)) - }) - } -} - -func TestOktaPolicyPriority(t *testing.T) { - o := &okta{logger: log.NewFieldLogger()} - cases := map[string]struct { - cfg corecfg.IDPConfig - want int - }{ - "returns configured value": { - cfg: &corecfg.IDPConfiguration{Okta: &corecfg.OktaIDPConfiguration{PolicyPriority: "5"}}, - want: 5, - }, - "returns default 1 when nil okta cfg": { - cfg: &corecfg.IDPConfiguration{}, - want: 1, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.want, o.policyPriority(tc.cfg)) - }) - } -} - func TestOktaPostProcessClientRegUsesIDPAccessToken(t *testing.T) { ts := httptest.NewServer(nil) defer ts.Close() @@ -656,7 +607,7 @@ func TestOktaPreProcessClientRequest(t *testing.T) { } } -func TestOktaCreatePolicyUsesConfigPriority(t *testing.T) { +func TestOktaCreatePolicyUsesDefaultPriority(t *testing.T) { apiClient := coreapi.NewClient(nil, "") oktaProvider := &okta{logger: log.NewFieldLogger()} var captured []byte @@ -677,14 +628,13 @@ func TestOktaCreatePolicyUsesConfigPriority(t *testing.T) { idpCfg := &corecfg.IDPConfiguration{ MetadataURL: ts.URL + oauthMetadataEndpoint, AuthConfig: &corecfg.IDPAuthConfiguration{AccessToken: accessToken}, - Okta: &corecfg.OktaIDPConfiguration{PolicyPriority: "5"}, } clientRes := &clientMetadata{ClientID: testClientID, Scope: []string{testScope}, GrantTypes: []string{GrantTypeClientCredentials}} assert.NoError(t, oktaProvider.postProcessClientRegistration(clientRes, idpCfg, apiClient)) var body map[string]any assert.NoError(t, json.Unmarshal(captured, &body)) - assert.EqualValues(t, testPolicyPriority, body["priority"]) + assert.EqualValues(t, 1, body["priority"]) } func TestOktaDeactivateThenDelete(t *testing.T) { diff --git a/pkg/config/externalidpconfig.go b/pkg/config/externalidpconfig.go index 8300cea74..2cf2e877b 100644 --- a/pkg/config/externalidpconfig.go +++ b/pkg/config/externalidpconfig.go @@ -35,10 +35,8 @@ const ( fldTitle = "title" fldType = "type" fldMetadataURL = "metadataUrl" - fldOktaTokenLifetimeMin = "okta.token_lifetime_min" fldOktaAppNameTemplate = "okta.appname_template" fldOktaPolicyNameTemplate = "okta.policyname_template" - fldOktaPolicyPriority = "okta.policy_priority" fldOktaScopeSources = "okta.scope_sources" fldOktaScopeBlacklist = "okta.scope_blacklist" fldExtraProperties = "extraProperties" @@ -68,8 +66,6 @@ const ( defaultOktaAppNameTemplate = OktaPlaceholderMPApplicationName + "-" + OktaPlaceholderOwningTeam + "-" + OktaPlaceholderCredentialName defaultOktaPolicyNameTemplate = OktaPlaceholderScope + "-" + OktaPlaceholderOAuthFlow - defaultOktaPolicyPriority = "1" - defaultOktaTokenLifetimeMin = "60" defaultOktaScopeSources = "swagger,gateway,okta" defaultOktaScopeBlacklist = "openid,profile,email,address,phone,offline_access" ) @@ -79,10 +75,8 @@ var configProperties = []string{ fldTitle, fldType, fldMetadataURL, - fldOktaTokenLifetimeMin, fldOktaAppNameTemplate, fldOktaPolicyNameTemplate, - fldOktaPolicyPriority, fldOktaScopeSources, fldOktaScopeBlacklist, fldExtraProperties, @@ -298,10 +292,8 @@ type IDPAuthConfiguration struct { // OktaIDPConfiguration - okta-specific configuration. type OktaIDPConfiguration struct { - TokenLifetimeMin string `json:"token_lifetime_min,omitempty"` AppNameTemplate string `json:"appname_template,omitempty"` PolicyNameTemplate string `json:"policyname_template,omitempty"` - PolicyPriority string `json:"policy_priority,omitempty"` ScopeSources string `json:"scope_sources,omitempty"` ScopeBlacklist string `json:"scope_blacklist,omitempty"` } @@ -349,18 +341,6 @@ func (i *IDPConfiguration) GetMetadataURL() string { return i.MetadataURL } -// GetTokenLifetimeMinutes - access token lifetime minutes for new Okta policy rules; defaults to 60. -func (i *IDPConfiguration) GetTokenLifetimeMinutes() int { - if i.Okta == nil || i.Okta.TokenLifetimeMin == "" { - return 60 - } - v, err := strconv.Atoi(i.Okta.TokenLifetimeMin) - if err != nil || v <= 0 { - return 60 - } - return v -} - // GetAppNameTemplate - Okta application name template; defaults to MP_APPLICATION_NAME-OWNING_TEAM-CREDENTIAL_NAME pattern. func (i *IDPConfiguration) GetAppNameTemplate() string { if i.Okta == nil || i.Okta.AppNameTemplate == "" { @@ -377,18 +357,6 @@ func (i *IDPConfiguration) GetPolicyNameTemplate() string { return i.Okta.PolicyNameTemplate } -// GetPolicyPriority - priority for new Okta authorization server policies; defaults to 1. -func (i *IDPConfiguration) GetPolicyPriority() int { - if i.Okta == nil || i.Okta.PolicyPriority == "" { - return 1 - } - v, err := strconv.Atoi(i.Okta.PolicyPriority) - if err != nil || v <= 0 { - return 1 - } - return v -} - // GetScopeSources - comma-separated active scope sources (swagger, gateway, okta); consumed by the v7 agent. func (i *IDPConfiguration) GetScopeSources() string { if i.Okta == nil || i.Okta.ScopeSources == "" { diff --git a/pkg/config/externalidpconfig_test.go b/pkg/config/externalidpconfig_test.go index f950399fb..3e37eb3e9 100644 --- a/pkg/config/externalidpconfig_test.go +++ b/pkg/config/externalidpconfig_test.go @@ -28,10 +28,8 @@ const ( envKeyIDPAuthClientSecret = "AGENTFEATURES_IDP_AUTH_CLIENTSECRET_1" envKeyIDPAuthRequestHeaders = "AGENTFEATURES_IDP_AUTH_REQUESTHEADERS_1" envKeyIDPAuthQueryParams = "AGENTFEATURES_IDP_AUTH_QUERYPARAMS_1" - envKeyIDPOktaTokenLifetimeMin = "AGENTFEATURES_IDP_OKTA_TOKEN_LIFETIME_MIN_1" envKeyIDPOktaAppNameTemplate = "AGENTFEATURES_IDP_OKTA_APPNAME_TEMPLATE_1" envKeyIDPOktaPolicyNameTemplate = "AGENTFEATURES_IDP_OKTA_POLICYNAME_TEMPLATE_1" - envKeyIDPOktaPolicyPriority = "AGENTFEATURES_IDP_OKTA_POLICY_PRIORITY_1" envKeyIDPOktaScopeSources = "AGENTFEATURES_IDP_OKTA_SCOPE_SOURCES_1" envKeyIDPOktaScopeBlacklist = "AGENTFEATURES_IDP_OKTA_SCOPE_BLACKLIST_1" ) @@ -162,18 +160,12 @@ func TestExternalIDPConfig(t *testing.T) { envKeyIDPAuthAccessToken: testAccessTok, }, }, - "okta token lifetime min config via env var": { - envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaTokenLifetimeMin: "120"}), - }, "okta appname template config via env var": { envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaAppNameTemplate: OktaPlaceholderMPApplicationName}), }, "okta policy name template config via env var": { envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaPolicyNameTemplate: OktaPlaceholderScope}), }, - "okta policy priority config via env var": { - envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaPolicyPriority: "3"}), - }, "okta scope sources config via env var": { envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeSources: "swagger,okta"}), }, @@ -221,8 +213,6 @@ func TestExternalIDPConfig(t *testing.T) { func TestOktaIDPConfigGetters(t *testing.T) { cases := map[string]struct { cfg *IDPConfiguration - wantTokenLifetime int - wantPolicyPriority int wantAppTemplate string wantPolicyTemplate string wantScopeSources string @@ -230,8 +220,6 @@ func TestOktaIDPConfigGetters(t *testing.T) { }{ "nil okta config returns all defaults": { cfg: &IDPConfiguration{}, - wantTokenLifetime: 60, - wantPolicyPriority: 1, wantAppTemplate: defaultOktaAppNameTemplate, wantPolicyTemplate: defaultOktaPolicyNameTemplate, wantScopeSources: defaultOktaScopeSources, @@ -239,34 +227,19 @@ func TestOktaIDPConfigGetters(t *testing.T) { }, "configured values are returned": { cfg: &IDPConfiguration{Okta: &OktaIDPConfiguration{ - TokenLifetimeMin: "90", - PolicyPriority: "5", AppNameTemplate: "my-" + OktaPlaceholderCredentialName, PolicyNameTemplate: OktaPlaceholderScope, ScopeSources: "swagger", ScopeBlacklist: "openid", }}, - wantTokenLifetime: 90, - wantPolicyPriority: 5, wantAppTemplate: "my-" + OktaPlaceholderCredentialName, wantPolicyTemplate: OktaPlaceholderScope, wantScopeSources: "swagger", wantScopeBlacklist: "openid", }, - "invalid int values fall back to defaults": { - cfg: &IDPConfiguration{Okta: &OktaIDPConfiguration{TokenLifetimeMin: "bad", PolicyPriority: "bad"}}, - wantTokenLifetime: 60, - wantPolicyPriority: 1, - wantAppTemplate: defaultOktaAppNameTemplate, - wantPolicyTemplate: defaultOktaPolicyNameTemplate, - wantScopeSources: defaultOktaScopeSources, - wantScopeBlacklist: defaultOktaScopeBlacklist, - }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - assert.Equal(t, tc.wantTokenLifetime, tc.cfg.GetTokenLifetimeMinutes()) - assert.Equal(t, tc.wantPolicyPriority, tc.cfg.GetPolicyPriority()) assert.Equal(t, tc.wantAppTemplate, tc.cfg.GetAppNameTemplate()) assert.Equal(t, tc.wantPolicyTemplate, tc.cfg.GetPolicyNameTemplate()) assert.Equal(t, tc.wantScopeSources, tc.cfg.GetScopeSources()) From ec481001fcdcc709fe531ab2ccd3d0e0e375efb2 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 13:08:47 -0700 Subject: [PATCH 03/22] APIGOV-32943 - update for owning team and group property --- pkg/agent/handler/accessrequest_test.go | 6 + pkg/agent/handler/managedapplication.go | 34 +++- pkg/agent/handler/managedapplication_test.go | 53 ++++++ pkg/authz/oauth/clients/okta.go | 65 +++++++ pkg/authz/oauth/clients/okta_test.go | 126 +++++++++++-- pkg/authz/oauth/oktaprovider.go | 75 ++++++++ pkg/authz/oauth/oktaprovider_test.go | 188 +++++++++++++++++++ pkg/authz/oauth/provider.go | 3 + pkg/authz/oauth/provider_test.go | 66 +++++++ pkg/config/externalidpconfig.go | 13 ++ pkg/config/externalidpconfig_test.go | 6 +- 11 files changed, 612 insertions(+), 23 deletions(-) diff --git a/pkg/agent/handler/accessrequest_test.go b/pkg/agent/handler/accessrequest_test.go index 1041be369..7517cdd70 100644 --- a/pkg/agent/handler/accessrequest_test.go +++ b/pkg/agent/handler/accessrequest_test.go @@ -418,6 +418,8 @@ type mockClient struct { isDeleting bool subError error deleteResCalled bool + getTeamResult []defs.PlatformTeam + getTeamErr error t *testing.T } @@ -459,6 +461,10 @@ func (m *mockClient) DeleteResourceInstance(ri v1.Interface) error { return nil } +func (m *mockClient) GetTeam(query map[string]string) ([]defs.PlatformTeam, error) { + return m.getTeamResult, m.getTeamErr +} + type mockARProvision struct { expectedAccessDetails map[string]interface{} expectedAPIID string diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index 50fcf9cef..129a39d60 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "fmt" "time" agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" @@ -18,12 +19,17 @@ const ( maFinalizer = "agent.managedapplication.provisioned" ) +type teamFetcher interface { + GetTeam(query map[string]string) ([]defs.PlatformTeam, error) +} + type managedApplication struct { marketplaceHandler - prov prov.ApplicationProvisioner - cache agentcache.Manager - client client - retryCount int + prov prov.ApplicationProvisioner + cache agentcache.Manager + client client + teamClient teamFetcher + retryCount int } func WithManagedAppRetryCount(rc int) func(c *managedApplication) { @@ -39,6 +45,9 @@ func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentc cache: cache, client: client, } + if tc, ok := client.(teamFetcher); ok { + ma.teamClient = tc + } for _, o := range opts { o(ma) } @@ -69,7 +78,7 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, ma := provManagedApp{ managedAppName: app.Name, - teamName: getTeamName(h.cache, app.Owner), + teamName: h.resolveTeamName(app.Owner), data: util.GetAgentDetails(app), consumerOrgID: getConsumerOrgID(app), id: app.Metadata.ID, @@ -206,6 +215,21 @@ func (a provManagedApp) GetConsumerOrgID() string { return a.consumerOrgID } +func (h *managedApplication) resolveTeamName(owner *apiv1.Owner) string { + if name := getTeamName(h.cache, owner); name != "" { + return name + } + if h.teamClient == nil || owner == nil || owner.ID == "" { + return "" + } + teams, err := h.teamClient.GetTeam(map[string]string{"query": fmt.Sprintf("id==%q", owner.ID)}) + if err != nil || len(teams) == 0 { + return "" + } + h.cache.AddTeam(&teams[0]) + return teams[0].Name +} + func getTeamName(cache getTeamByID, owner *apiv1.Owner) string { teamName := "" if owner != nil && owner.ID != "" { diff --git a/pkg/agent/handler/managedapplication_test.go b/pkg/agent/handler/managedapplication_test.go index db8f004e1..3e1663d34 100644 --- a/pkg/agent/handler/managedapplication_test.go +++ b/pkg/agent/handler/managedapplication_test.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "testing" agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" @@ -194,6 +195,58 @@ func TestManagedApplicationHandler_deleting(t *testing.T) { } } +func TestManagedApplicationHandlerCacheMiss(t *testing.T) { + cases := map[string]struct { + getTeamResult []defs.PlatformTeam + getTeamErr error + wantTeamName string + }{ + "API returns team on cache miss": { + getTeamResult: []defs.PlatformTeam{{ID: team.ID, Name: teamName}}, + wantTeamName: teamName, + }, + "API returns empty on cache miss": { + getTeamResult: []defs.PlatformTeam{}, + wantTeamName: "", + }, + "API errors on cache miss": { + getTeamErr: fmt.Errorf("network error"), + wantTeamName: "", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + app := managedAppForTest + app.Status.Level = prov.Pending.String() + + p := &mockManagedAppProv{ + expectedManagedApp: app.Name, + expectedManagedAppData: util.GetAgentDetails(&app), + expectedTeamName: tc.wantTeamName, + status: mock.MockRequestStatus{ + Status: prov.Success, + Msg: "msg", + Properties: map[string]string{"status_key": "status_val"}, + }, + t: t, + } + c := &mockClient{ + expectedStatus: prov.Success.String(), + getTeamResult: tc.getTeamResult, + getTeamErr: tc.getTeamErr, + t: t, + } + // team intentionally NOT pre-loaded into cache to exercise the fallback path + cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + h := NewManagedApplicationHandler(p, cm, c) + + ri, _ := app.AsInstance() + err := h.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) + assert.Nil(t, err) + }) + } +} + func TestManagedApplicationHandler_wrong_kind(t *testing.T) { cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) c := &mockClient{ diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 4f06ed05f..4f7881084 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" coreapi "github.com/Axway/agent-sdk/pkg/api" @@ -19,6 +20,15 @@ type Okta struct { logger log.FieldLogger } +type oktaGroupProfile struct { + Name string `json:"name"` +} + +type oktaGroupSearchResult struct { + ID string `json:"id"` + Profile oktaGroupProfile `json:"profile"` +} + type oktaPolicyListResult struct { ID string `json:"id"` Name string `json:"name"` @@ -373,6 +383,61 @@ func (o *Okta) DeleteApp(appID string) error { return nil } +// FindGroupByName searches Okta for a group with the given name and returns its ID. +// Returns ("", nil) when no matching group exists. +func (o *Okta) FindGroupByName(groupName string) (string, error) { + groupName = strings.TrimSpace(groupName) + if groupName == "" { + return "", nil + } + endpoint := fmt.Sprintf("%s/api/v1/groups?q=%s", o.BaseURL, url.QueryEscape(groupName)) + o.logger.WithField("groupName", groupName).WithField("endpoint", endpoint).Trace("searching for Okta group") + + var groups []oktaGroupSearchResult + if err := o.doGetJSON(endpoint, &groups); err != nil { + return "", err + } + + for _, g := range groups { + if g.Profile.Name == groupName { + return g.ID, nil + } + } + return "", nil +} + +// AssignGroupToApp adds an Okta application to the given group. +func (o *Okta) AssignGroupToApp(appID, groupID string) error { + endpoint := fmt.Sprintf("%s/api/v1/apps/%s/groups/%s", o.BaseURL, appID, groupID) + o.logger.WithField("appID", appID).WithField("groupID", groupID).WithField("endpoint", endpoint).Trace("assigning group to Okta app") + resp, err := o.doRequest(coreapi.PUT, endpoint, nil) + if err != nil { + return err + } + if !isStatus(resp.Code, http.StatusOK, http.StatusCreated) { + return o.unexpectedStatusError(coreapi.PUT, endpoint, resp) + } + return nil +} + +// UnassignGroupFromApp removes an Okta application from the given group. +// A 404 response is treated as success (app or group already gone). +func (o *Okta) UnassignGroupFromApp(appID, groupID string) error { + endpoint := fmt.Sprintf("%s/api/v1/apps/%s/groups/%s", o.BaseURL, appID, groupID) + o.logger.WithField("appID", appID).WithField("groupID", groupID).WithField("endpoint", endpoint).Trace("removing app from Okta group") + resp, err := o.doRequest(coreapi.DELETE, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusNoContent, http.StatusOK) { + return o.unexpectedStatusError(coreapi.DELETE, endpoint, resp) + } + return nil +} + func ensureMap(parent map[string]interface{}, key string) map[string]interface{} { child, ok := parent[key].(map[string]interface{}) if ok && child != nil { diff --git a/pkg/authz/oauth/clients/okta_test.go b/pkg/authz/oauth/clients/okta_test.go index 6bf70a031..cc135b9f1 100644 --- a/pkg/authz/oauth/clients/okta_test.go +++ b/pkg/authz/oauth/clients/okta_test.go @@ -12,13 +12,19 @@ import ( ) const ( - removeClientID = "remove-me" - caseErrForbidden = "returns error on forbidden" - testPolicyName = "my-policy" - testClientID = "client1" - testRuleName = "rule-1" - testGrantType = "client_credentials" - testRuleScope = "read:api" + removeClientID = "remove-me" + errForbidden = "returns error on forbidden" + nilOn200 = "returns nil on 200" + nilOn204 = "returns nil on 204" + notFoundAsSuccess = "treats 404 as success" + testPolicyName = "my-policy" + testClientID = "client1" + testRuleName = "rule-1" + testGrantType = "client_credentials" + testRuleScope = "read:api" + testGroupID = "grp-123" + testGroupName = "Marketplace" + testAppID = "app-abc" ) func newTestOktaClient(t *testing.T, handler http.HandlerFunc) (*Okta, func()) { @@ -44,7 +50,7 @@ func TestOktaUpdatePolicy(t *testing.T) { code int wantErr bool }{ - caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, "returns nil on ok": {code: http.StatusOK, wantErr: false}, } for name, tc := range cases { @@ -66,7 +72,7 @@ func TestOktaCreatePolicy(t *testing.T) { wantErr bool }{ "returns created policy on 201": {code: http.StatusCreated, body: `{"id":"pol-new","name":"my-policy"}`, wantErr: false}, - caseErrForbidden: {code: http.StatusForbidden, body: `forbidden`, wantErr: true}, + errForbidden: {code: http.StatusForbidden, body: `forbidden`, wantErr: true}, } for name, tc := range cases { tc := tc @@ -187,7 +193,7 @@ func TestOktaRemoveClientFromPolicy(t *testing.T) { wantErr: false, wantPUT: true, }, - caseErrForbidden: { + errForbidden: { code: http.StatusForbidden, policy: policyWith("keep-me", removeClientID), wantErr: true, @@ -236,10 +242,10 @@ func TestOktaDeactivateApp(t *testing.T) { code int wantErr bool }{ - "returns nil on 200": {code: http.StatusOK, wantErr: false}, - "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, - "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, - caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + nilOn200: {code: http.StatusOK, wantErr: false}, + nilOn204: {code: http.StatusNoContent, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, } for name, tc := range cases { tc := tc @@ -253,14 +259,100 @@ func TestOktaDeactivateApp(t *testing.T) { } } +func TestOktaFindGroupByName(t *testing.T) { + cases := map[string]struct { + responseCode int + responseBody string + wantID string + wantErr bool + }{ + "returns group ID when found": { + responseCode: http.StatusOK, + responseBody: `[{"id":"grp-123","profile":{"name":"Marketplace"}},{"id":"grp-999","profile":{"name":"MarketplaceOther"}}]`, + wantID: testGroupID, + }, + "returns empty string when no exact match": { + responseCode: http.StatusOK, + responseBody: `[{"id":"grp-999","profile":{"name":"MarketplaceOther"}}]`, + wantID: "", + }, + "returns empty string on empty list": { + responseCode: http.StatusOK, + responseBody: `[]`, + wantID: "", + }, + errForbidden: { + responseCode: http.StatusForbidden, + responseBody: `forbidden`, + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.responseCode) + _, _ = w.Write([]byte(tc.responseBody)) + }) + defer close() + got, err := client.FindGroupByName(testGroupName) + assertOktaErr(t, err, tc.wantErr) + if !tc.wantErr { + assert.Equal(t, tc.wantID, got) + } + }) + } +} + +func TestOktaAssignGroupToApp(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + nilOn200: {code: http.StatusOK, wantErr: false}, + "returns nil on 201": {code: http.StatusCreated, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, + "returns error on 404": {code: http.StatusNotFound, wantErr: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.AssignGroupToApp(testAppID, testGroupID), tc.wantErr) + }) + } +} + +func TestOktaUnassignGroupFromApp(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + nilOn204: {code: http.StatusNoContent, wantErr: false}, + nilOn200: {code: http.StatusOK, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.UnassignGroupFromApp(testAppID, testGroupID), tc.wantErr) + }) + } +} + func TestOktaDeleteApp(t *testing.T) { cases := map[string]struct { code int wantErr bool }{ - "returns nil on 204": {code: http.StatusNoContent, wantErr: false}, - "treats 404 as success": {code: http.StatusNotFound, wantErr: false}, - caseErrForbidden: {code: http.StatusForbidden, wantErr: true}, + nilOn204: {code: http.StatusNoContent, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, } for name, tc := range cases { tc := tc diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index a284f563c..0a228cedc 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -117,6 +117,10 @@ func (i *okta) postProcessClientRegistration(clientRes ClientMetadata, idp corec return err } + if err := i.handleGroupAssignment(oktaClient, clientRes.GetClientID(), idp); err != nil { + return err + } + i.logger.WithField("clientID", clientRes.GetClientID()).Info("completed Okta post-registration hook") return nil } @@ -152,6 +156,10 @@ func (i *okta) postProcessClientUnregister(clientID string, idp corecfg.IDPConfi return err } + if err := i.handleGroupRemoval(oktaClient, clientID, idp); err != nil { + return err + } + if err := i.handlePerScopePolicyUnassign(oktaClient, clientID, scopes, idp, authServerID, grantType); err != nil { return err } @@ -308,3 +316,70 @@ func (i *okta) preProcessClientRequest(clientRequest *clientMetadata) { func (i *okta) getAuthorizationHeaderPrefix() string { return clients.OktaAuthHeaderPrefix } + +func (i *okta) handleGroupAssignment(oktaClient *clients.Okta, clientID string, idp corecfg.IDPConfig) error { + groupName := oktaGroupName(idp) + if groupName == "" { + return nil + } + i.logger.WithField("clientID", clientID).WithField("groupName", groupName).Trace("adding app to Okta group") + groupID, err := oktaClient.FindGroupByName(groupName) + if err != nil { + return fmt.Errorf("error finding Okta group %q: %w", groupName, err) + } + if groupID == "" { + return fmt.Errorf("configured Okta group %q not found", groupName) + } + return oktaClient.AssignGroupToApp(clientID, groupID) +} + +func (i *okta) handleGroupRemoval(oktaClient *clients.Okta, clientID string, idp corecfg.IDPConfig) error { + groupName := oktaGroupName(idp) + if groupName == "" { + return nil + } + i.logger.WithField("clientID", clientID).WithField("groupName", groupName).Trace("removing app from Okta group") + groupID, err := oktaClient.FindGroupByName(groupName) + if err != nil { + return fmt.Errorf("error finding Okta group %q: %w", groupName, err) + } + if groupID == "" { + return nil + } + return oktaClient.UnassignGroupFromApp(clientID, groupID) +} + +func oktaGroupName(idp corecfg.IDPConfig) string { + cfg, ok := idp.(interface{ GetOktaGroup() string }) + if !ok { + return "" + } + return cfg.GetOktaGroup() +} + +func validateOktaGroupExists(idp corecfg.IDPConfig, apiClient coreapi.Client) error { + groupName := oktaGroupName(idp) + if groupName == "" { + return nil + } + var apiToken string + if authCfg := idp.GetAuthConfig(); authCfg != nil { + apiToken = authCfg.GetAccessToken() + } + if apiToken == "" { + return nil + } + baseURL, err := oktaBaseURLFromMetadataURL(idp.GetMetadataURL()) + if err != nil { + return err + } + oktaClient := clients.New(apiClient, baseURL, apiToken) + groupID, err := oktaClient.FindGroupByName(groupName) + if err != nil { + return fmt.Errorf("error looking up Okta group %q: %w", groupName, err) + } + if groupID == "" { + return fmt.Errorf("configured Okta group %q not found", groupName) + } + return nil +} diff --git a/pkg/authz/oauth/oktaprovider_test.go b/pkg/authz/oauth/oktaprovider_test.go index 97b40d184..95c8bed5c 100644 --- a/pkg/authz/oauth/oktaprovider_test.go +++ b/pkg/authz/oauth/oktaprovider_test.go @@ -35,6 +35,10 @@ const ( oktaPolicyRulesEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/rules" oktaDeactivateEndpoint = "/api/v1/apps/app123/lifecycle/deactivate" oktaDeleteEndpoint = "/api/v1/apps/app123" + testGroupName = "Marketplace" + testGroupID = "grp-456" + oktaGroupsEndpoint = "/api/v1/groups" + oktaAppGroupEndpoint = "/api/v1/apps/app123/groups/grp-456" ) // oktaScriptedServer drives httptest with per-route handlers and call-count tracking. @@ -693,6 +697,190 @@ func TestOktaPostProcessClientUnregisterAbortOnRemoveError(t *testing.T) { assert.Equal(t, 1, calls[http.MethodGet+" "+oktaPoliciesEndpointByID], "policy list GET must stop after first scope fails") } +func groupListHandler(id, name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, `[{"id":%q,"profile":{"name":%q}}]`, id, name) + } +} + +func newIDPCredentialWithGroup(tsURL, group string) *corecfg.IDPConfiguration { + cfg := newIDPCredential(tsURL) + cfg.Okta = &corecfg.OktaIDPConfiguration{Group: group} + return cfg +} + +func TestValidateOktaGroupExists(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + + cases := map[string]struct { + group string + routes map[string]http.HandlerFunc + wantErr bool + }{ + "no group configured is a no-op": { + group: "", + routes: map[string]http.HandlerFunc{}, + wantErr: false, + }, + "configured group found returns nil": { + group: testGroupName, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaGroupsEndpoint: groupListHandler(testGroupID, testGroupName), + }, + wantErr: false, + }, + "configured group not found returns error": { + group: testGroupName, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaGroupsEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }, + }, + wantErr: true, + }, + "API error returns error": { + group: testGroupName, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaGroupsEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }, + }, + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts, _ := newOktaScriptedServer(t, testAuthHeader, tc.routes) + defer ts.Close() + cfg := newIDPCredentialWithGroup(ts.URL, tc.group) + err := validateOktaGroupExists(cfg, apiClient) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestOktaPostProcessClientRegistrationWithGroup(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + cases := map[string]struct { + routes map[string]http.HandlerFunc + wantMinCalls map[string]int + wantErr bool + }{ + "group assigned after per-scope policy": { + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q}`, oktaPolicyID) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + http.MethodGet + " " + oktaGroupsEndpoint: groupListHandler(testGroupID, testGroupName), + http.MethodPut + " " + oktaAppGroupEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaGroupsEndpoint: 1, + http.MethodPut + " " + oktaAppGroupEndpoint: 1, + }, + }, + "group not found returns error": { + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q}`, oktaPolicyID) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + http.MethodGet + " " + oktaGroupsEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }, + }, + wantErr: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts, scripted := newOktaScriptedServer(t, testAuthHeader, tc.routes) + defer ts.Close() + cfg := newIDPCredentialWithGroup(ts.URL, testGroupName) + clientRes := &clientMetadata{ClientID: testClientID, Scope: []string{testScope}, GrantTypes: []string{GrantTypeClientCredentials}} + err := oktaProvider.postProcessClientRegistration(clientRes, cfg, apiClient) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertMinCalls(t, scripted.getCalls(), tc.wantMinCalls) + }) + } +} + +func TestOktaPostProcessClientUnregisterWithGroup(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + cases := map[string]struct { + routes map[string]http.HandlerFunc + wantMinCalls map[string]int + wantErr bool + }{ + "group removed after app delete": { + routes: map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, + http.MethodGet + " " + oktaGroupsEndpoint: groupListHandler(testGroupID, testGroupName), + http.MethodDelete + " " + oktaAppGroupEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaGroupsEndpoint: 1, + http.MethodDelete + " " + oktaAppGroupEndpoint: 1, + }, + }, + "group not in Okta during unregister is skipped": { + routes: map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, + http.MethodGet + " " + oktaGroupsEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaGroupsEndpoint: 1, + }, + wantErr: false, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts, scripted := newOktaScriptedServer(t, testAuthHeader, tc.routes) + defer ts.Close() + cfg := newIDPCredentialWithGroup(ts.URL, testGroupName) + err := oktaProvider.postProcessClientUnregister(testClientID, cfg, apiClient, nil, GrantTypeClientCredentials) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertMinCalls(t, scripted.getCalls(), tc.wantMinCalls) + }) + } +} + func TestPolicyNameTemplate(t *testing.T) { cases := map[string]struct { cfg corecfg.IDPConfig diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index c4a5650e1..ddae221da 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -150,6 +150,9 @@ func NewProvider(idp corecfg.IDPConfig, tlsCfg corecfg.TLSConfig, proxyURL strin if err := validateOktaTemplates(idp); err != nil { return nil, err } + if err := validateOktaGroupExists(idp, apiClient); err != nil { + return nil, err + } } return p, nil diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 902eeca1d..6b0467875 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -512,3 +512,69 @@ func TestUnregisterClientCleanupAndDeleteFail(t *testing.T) { assert.Contains(t, err.Error(), "OAuth client deletion failed") assert.Equal(t, int32(1), deleteCalls.Load()) } + +func TestNewProviderValidatesOktaGroup(t *testing.T) { + cases := map[string]struct { + groupsCode int + groupsBody string + wantErr bool + errContains string + }{ + "group found — NewProvider succeeds": { + groupsCode: http.StatusOK, + groupsBody: `[{"id":"grp-1","profile":{"name":"` + testGroupName + `"}}]`, + }, + "group not found — NewProvider fails": { + groupsCode: http.StatusOK, + groupsBody: `[]`, + wantErr: true, + errContains: "configured Okta group", + }, + "groups API error — NewProvider fails": { + groupsCode: http.StatusForbidden, + groupsBody: `forbidden`, + wantErr: true, + errContains: "Okta group", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, ".well-known"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"issuer":"` + ts.URL + `","token_endpoint":"` + ts.URL + `/token","registration_endpoint":"` + ts.URL + `/register","authorization_endpoint":"` + ts.URL + `/auth"}`)) + case r.URL.Path == oktaGroupsEndpoint: + w.WriteHeader(tc.groupsCode) + _, _ = w.Write([]byte(tc.groupsBody)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + idpCfg := &config.IDPConfiguration{ + Name: "test", + Type: TypeOkta, + MetadataURL: ts.URL + testAuthServerMetadataURL, + Okta: &config.OktaIDPConfiguration{Group: testGroupName}, + AuthConfig: &config.IDPAuthConfiguration{ + Type: config.AccessToken, + AccessToken: testToken, + }, + } + provider, err := NewProvider(idpCfg, config.NewTLSConfig(), "", 10*time.Second) + if tc.wantErr { + assert.Error(t, err) + assert.Nil(t, provider) + if tc.errContains != "" { + assert.Contains(t, err.Error(), tc.errContains) + } + return + } + assert.NoError(t, err) + assert.NotNil(t, provider) + }) + } +} diff --git a/pkg/config/externalidpconfig.go b/pkg/config/externalidpconfig.go index 2cf2e877b..1271f3f6e 100644 --- a/pkg/config/externalidpconfig.go +++ b/pkg/config/externalidpconfig.go @@ -35,6 +35,7 @@ const ( fldTitle = "title" fldType = "type" fldMetadataURL = "metadataUrl" + fldOktaGroup = "okta.group" fldOktaAppNameTemplate = "okta.appname_template" fldOktaPolicyNameTemplate = "okta.policyname_template" fldOktaScopeSources = "okta.scope_sources" @@ -75,6 +76,7 @@ var configProperties = []string{ fldTitle, fldType, fldMetadataURL, + fldOktaGroup, fldOktaAppNameTemplate, fldOktaPolicyNameTemplate, fldOktaScopeSources, @@ -267,6 +269,8 @@ type IDPConfig interface { GetRequestHeaders() map[string]string // GetQueryParams - set of additional query parameters to be applied when registering the client GetQueryParams() map[string]string + // GetOktaGroup - name of the Okta group that newly registered apps are added to + GetOktaGroup() string // GetTLSConfig - tls config for IDP connection GetTLSConfig() TLSConfig // validate - Validates the IDP configuration @@ -292,6 +296,7 @@ type IDPAuthConfiguration struct { // OktaIDPConfiguration - okta-specific configuration. type OktaIDPConfiguration struct { + Group string `json:"group,omitempty"` AppNameTemplate string `json:"appname_template,omitempty"` PolicyNameTemplate string `json:"policyname_template,omitempty"` ScopeSources string `json:"scope_sources,omitempty"` @@ -373,6 +378,14 @@ func (i *IDPConfiguration) GetScopeBlacklist() string { return i.Okta.ScopeBlacklist } +// GetOktaGroup - name of the Okta group that newly registered apps are added to. +func (i *IDPConfiguration) GetOktaGroup() string { + if i.Okta == nil { + return "" + } + return i.Okta.Group +} + // GetExtraProperties - set of additional properties to be applied when registering the client func (i *IDPConfiguration) GetExtraProperties() map[string]interface{} { return i.ExtraProperties diff --git a/pkg/config/externalidpconfig_test.go b/pkg/config/externalidpconfig_test.go index 3e37eb3e9..4a75939ce 100644 --- a/pkg/config/externalidpconfig_test.go +++ b/pkg/config/externalidpconfig_test.go @@ -217,6 +217,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { wantPolicyTemplate string wantScopeSources string wantScopeBlacklist string + wantGroup string }{ "nil okta config returns all defaults": { cfg: &IDPConfiguration{}, @@ -224,9 +225,11 @@ func TestOktaIDPConfigGetters(t *testing.T) { wantPolicyTemplate: defaultOktaPolicyNameTemplate, wantScopeSources: defaultOktaScopeSources, wantScopeBlacklist: defaultOktaScopeBlacklist, + wantGroup: "", }, "configured values are returned": { cfg: &IDPConfiguration{Okta: &OktaIDPConfiguration{ + Group: "Marketplace", AppNameTemplate: "my-" + OktaPlaceholderCredentialName, PolicyNameTemplate: OktaPlaceholderScope, ScopeSources: "swagger", @@ -236,6 +239,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { wantPolicyTemplate: OktaPlaceholderScope, wantScopeSources: "swagger", wantScopeBlacklist: "openid", + wantGroup: "Marketplace", }, } for name, tc := range cases { @@ -244,6 +248,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { assert.Equal(t, tc.wantPolicyTemplate, tc.cfg.GetPolicyNameTemplate()) assert.Equal(t, tc.wantScopeSources, tc.cfg.GetScopeSources()) assert.Equal(t, tc.wantScopeBlacklist, tc.cfg.GetScopeBlacklist()) + assert.Equal(t, tc.wantGroup, tc.cfg.GetOktaGroup()) }) } } @@ -251,7 +256,6 @@ func TestOktaIDPConfigGetters(t *testing.T) { func TestRemovedSymbols(t *testing.T) { typ := reflect.TypeFor[OktaIDPConfiguration]() cases := map[string]struct{}{ - "Group": {}, "Policy": {}, } for field := range cases { From 2c9245c973c3020dc25b1840dd7c014cd4dcc01b Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 13:34:12 -0700 Subject: [PATCH 04/22] APIGOV-32943 - update for scopes --- pkg/authz/oauth/oktaprovider.go | 3 ++ pkg/authz/oauth/provider.go | 9 ++++ pkg/authz/oauth/provider_test.go | 74 ++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index 0a228cedc..776f77649 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -186,6 +186,9 @@ func (i *okta) handlePerScopePolicyAssign( replacer := strings.NewReplacer(corecfg.OktaPlaceholderOAuthFlow, normalizedFlow) for _, scope := range scopes { + if strings.TrimSpace(scope) == "" { + continue + } policyName := replacer.Replace(strings.ReplaceAll(template, corecfg.OktaPlaceholderScope, scope)) if err := i.assignScopePolicy(oktaClient, clientRes, idp, authServerID, grantType, policyName, scope); err != nil { return err diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index ddae221da..fc5911bd0 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -449,6 +449,15 @@ func (p *provider) RegisterClient(clientReq ClientMetadata) (ClientMetadata, err if !p.cfg.GetAuthConfig().UseRegistrationAccessToken() { clientRes.RegistrationAccessToken = "" } + // Some IdPs (notably Okta for client_credentials) omit scope/grant_types + // from the registration response. Carry them from the request so the + // post-hook (per-scope policy assignment) has the data it needs. + if len(clientRes.Scope) == 0 { + clientRes.Scope = Scopes(clientReq.GetScopes()) + } + if len(clientRes.GrantTypes) == 0 { + clientRes.GrantTypes = clientReq.GetGrantTypes() + } if err := p.runPostRegistrationHook(clientRes, authPrefix, token); err != nil { return nil, err diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 6b0467875..294db9f79 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -513,6 +513,80 @@ func TestUnregisterClientCleanupAndDeleteFail(t *testing.T) { assert.Equal(t, int32(1), deleteCalls.Load()) } +func TestRegisterClientOktaScopesRequest(t *testing.T) { + cases := map[string]struct { + regResponseBody string + requestScopes Scopes + wantPolicyCalls int32 + }{ + "response omits scopes — policy creation runs from request scopes": { + regResponseBody: `{"client_id":"` + testClientID + `","client_secret":"sec-1"}`, + requestScopes: Scopes{testScope}, + wantPolicyCalls: 1, + }, + "response includes scopes — policy creation runs normally": { + regResponseBody: `{"client_id":"` + testClientID + `","client_secret":"sec-1","scope":"` + testScope + `","grant_types":["client_credentials"]}`, + requestScopes: Scopes{testScope}, + wantPolicyCalls: 1, + }, + "no scopes in request — policy creation skipped": { + regResponseBody: `{"client_id":"` + testClientID + `","client_secret":"sec-1"}`, + requestScopes: nil, + wantPolicyCalls: 0, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var policyCalls atomic.Int32 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, ".well-known"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"issuer":"` + srv.URL + `","token_endpoint":"` + srv.URL + `/token","registration_endpoint":"` + srv.URL + `/register","authorization_endpoint":"` + srv.URL + `/auth"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/register": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(tc.regResponseBody)) + case r.Method == http.MethodGet && r.URL.Path == oktaPoliciesEndpointByID: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost && r.URL.Path == oktaPoliciesEndpointByID: + policyCalls.Add(1) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"` + oktaPolicyID + `"}`)) + case r.Method == http.MethodPost && r.URL.Path == oktaPolicyRulesEndpoint: + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + idpCfg := &config.IDPConfiguration{ + Name: "test", + Type: TypeOkta, + MetadataURL: srv.URL + testAuthServerMetadataURL, + AuthConfig: &config.IDPAuthConfiguration{ + Type: config.AccessToken, + AccessToken: testToken, + }, + } + p, err := NewProvider(idpCfg, config.NewTLSConfig(), "", 10*time.Second) + assert.NoError(t, err) + + clientReq := &clientMetadata{ + ClientName: "test-app", + GrantTypes: []string{GrantTypeClientCredentials}, + Scope: tc.requestScopes, + } + cr, err := p.RegisterClient(clientReq) + assert.NoError(t, err) + assert.NotNil(t, cr) + assert.Equal(t, tc.wantPolicyCalls, policyCalls.Load()) + }) + } +} + func TestNewProviderValidatesOktaGroup(t *testing.T) { cases := map[string]struct { groupsCode int From 88e69c1c6ad552473e67fe2a39bf72342c54341f Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 14:08:52 -0700 Subject: [PATCH 05/22] APIGOV-32943 - blacklist processing --- pkg/authz/oauth/provider.go | 40 +++++++++-- pkg/authz/oauth/provider_test.go | 110 +++++++++++++++++++++++++++---- 2 files changed, 135 insertions(+), 15 deletions(-) diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index fc5911bd0..b0e8fb616 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -360,12 +360,44 @@ func (p *provider) GetAuthorizationEndpoint() string { return "" } -// GetSupportedScopes - returns the global scopes supported by provider +// GetSupportedScopes - returns the scopes supported by the provider, with any +// configured blacklist removed. Non-Okta providers receive the raw scope list +// unchanged. func (p *provider) GetSupportedScopes() []string { - if p.authServerMetadata != nil { - return p.authServerMetadata.ScopesSupported + if p.authServerMetadata == nil { + return []string{""} } - return []string{""} + scopes := p.authServerMetadata.ScopesSupported + if p.cfg.GetIDPType() != TypeOkta { + return scopes + } + bl, ok := p.cfg.(interface{ GetScopeBlacklist() string }) + if !ok || bl.GetScopeBlacklist() == "" { + return scopes + } + return filterScopeBlacklist(scopes, bl.GetScopeBlacklist()) +} + +// filterScopeBlacklist removes any scope that appears in the comma-separated +// blacklist string. Order of the remaining scopes is preserved. +func filterScopeBlacklist(scopes []string, blacklist string) []string { + if len(scopes) == 0 { + return scopes + } + denied := make(map[string]struct{}) + for _, s := range strings.Split(blacklist, ",") { + s = strings.TrimSpace(s) + if s != "" { + denied[s] = struct{}{} + } + } + out := make([]string, 0, len(scopes)) + for _, s := range scopes { + if _, blocked := denied[s]; !blocked { + out = append(out, s) + } + } + return out } // GetSupportedGrantTypes - returns the grant type supported by provider diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 294db9f79..793e49329 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -21,6 +21,13 @@ const ( testAuthServerMetadataURL = "/oauth2/authorizationID/.well-known/oauth-authorization-server" testMetadataURL = "/metadata" testRegisterURL = "/register/cid-1" + + scopeOpenID = "openid" + scopeProfile = "profile" + scopeEmail = "email" + scopeWriteAPI = "write:api" + + blacklistOpenIDProfile = "openid,profile" ) type providerTestCase struct { @@ -74,7 +81,7 @@ func TestProvider(t *testing.T) { TokenEndpointAuthMethod: config.ClientSecretBasic, ResponseTypes: []string{AuthResponseCode}, Scope: []string{"read", "write"}, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ "key": "value", oktaApplicationType: oktaAppTypeWeb, }, @@ -100,7 +107,7 @@ func TestProvider(t *testing.T) { TokenEndpointAuthMethod: config.ClientSecretBasic, ResponseTypes: []string{AuthResponseToken}, Scope: []string{"read", "write"}, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ "key": "value", }, }, @@ -128,7 +135,7 @@ func TestProvider(t *testing.T) { TokenEndpointAuthMethod: config.ClientSecretBasic, ResponseTypes: []string{}, Scope: []string{"read", "write"}, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ "key": "value", }, }, @@ -152,7 +159,7 @@ func TestProvider(t *testing.T) { TokenEndpointAuthMethod: config.ClientSecretBasic, ResponseTypes: []string{}, Scope: []string{"read", "write"}, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ "key": "value", }, }, @@ -163,7 +170,6 @@ func TestProvider(t *testing.T) { }, } for name, tc := range cases { - tc := tc t.Run(name, func(t *testing.T) { runProviderTestCase(t, tc) }) @@ -267,13 +273,13 @@ func TestNewProviderValidatesExtraProperties(t *testing.T) { tests := map[string]struct { idpType string - extraProperties map[string]interface{} + extraProperties map[string]any expectError bool errorContains string }{ "Valid Okta provider with PKCE and browser type": { idpType: TypeOkta, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ oktaPKCERequired: true, oktaApplicationType: oktaAppTypeBrowser, }, @@ -281,7 +287,7 @@ func TestNewProviderValidatesExtraProperties(t *testing.T) { }, "Invalid Okta provider with PKCE and service type": { idpType: TypeOkta, - extraProperties: map[string]interface{}{ + extraProperties: map[string]any{ oktaPKCERequired: true, oktaApplicationType: oktaAppTypeService, }, @@ -290,7 +296,7 @@ func TestNewProviderValidatesExtraProperties(t *testing.T) { }, "Valid generic provider": { idpType: "generic", - extraProperties: map[string]interface{}{}, + extraProperties: map[string]any{}, expectError: false, }, } @@ -341,7 +347,7 @@ func (f *failingHookIDP) preProcessClientRequest(clientRequest *clientMetadata) // No preprocessing needed for this mock IDP implementation } -func (f *failingHookIDP) validateExtraProperties(extraProps map[string]interface{}) error { +func (f *failingHookIDP) validateExtraProperties(extraProps map[string]any) error { return nil } @@ -371,7 +377,6 @@ func TestRegisterClientRollBack(t *testing.T) { } for name, tc := range tests { - tc := tc t.Run(name, func(t *testing.T) { var deleteCalls atomic.Int32 var registerCalls atomic.Int32 @@ -652,3 +657,86 @@ func TestNewProviderValidatesOktaGroup(t *testing.T) { }) } } + +func TestFilterScopeBlacklist(t *testing.T) { + cases := map[string]struct { + scopes []string + blacklist string + want []string + }{ + "removes blacklisted scopes": { + scopes: []string{scopeOpenID, scopeProfile, testScope, scopeWriteAPI}, + blacklist: blacklistOpenIDProfile, + want: []string{testScope, scopeWriteAPI}, + }, + "empty blacklist returns all scopes": { + scopes: []string{scopeOpenID, testScope}, + blacklist: "", + want: []string{scopeOpenID, testScope}, + }, + "blacklist with whitespace is trimmed": { + scopes: []string{scopeOpenID, testScope}, + blacklist: " openid , profile ", + want: []string{testScope}, + }, + "no scopes match blacklist returns all": { + scopes: []string{testScope, scopeWriteAPI}, + blacklist: blacklistOpenIDProfile, + want: []string{testScope, scopeWriteAPI}, + }, + "all scopes blacklisted returns empty": { + scopes: []string{scopeOpenID, scopeProfile}, + blacklist: blacklistOpenIDProfile, + want: []string{}, + }, + "nil scopes returns nil": { + scopes: nil, + blacklist: scopeOpenID, + want: nil, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := filterScopeBlacklist(tc.scopes, tc.blacklist) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGetSupportedScopesAppliesBlacklist(t *testing.T) { + cases := map[string]struct { + cfg config.IDPConfig + rawScopes []string + wantScopes []string + }{ + "Okta config with blacklist filters scopes": { + cfg: &config.IDPConfiguration{ + Type: TypeOkta, + Okta: &config.OktaIDPConfiguration{ScopeBlacklist: "openid,profile"}, + }, + rawScopes: []string{"openid", "profile", "read:api"}, + wantScopes: []string{"read:api"}, + }, + "Okta config with default blacklist filters defaults": { + cfg: &config.IDPConfiguration{Type: TypeOkta, Okta: &config.OktaIDPConfiguration{}}, + rawScopes: []string{"openid", "profile", "email", "read:api"}, + wantScopes: []string{"read:api"}, + }, + "non-Okta config returns scopes unfiltered": { + cfg: &config.IDPConfiguration{Type: "generic"}, + rawScopes: []string{"openid", "profile", "read:api"}, + wantScopes: []string{"openid", "profile", "read:api"}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := &provider{ + cfg: tc.cfg, + authServerMetadata: &AuthorizationServerMetadata{ + ScopesSupported: tc.rawScopes, + }, + } + assert.Equal(t, tc.wantScopes, p.GetSupportedScopes()) + }) + } +} From e1c9356a2b21467b0fcd1c752f48008269e71519 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 14:51:12 -0700 Subject: [PATCH 06/22] APIGOV-32943 - change to guid for query --- pkg/agent/handler/accessrequest_test.go | 2 ++ pkg/agent/handler/managedapplication.go | 2 +- pkg/agent/handler/managedapplication_test.go | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/agent/handler/accessrequest_test.go b/pkg/agent/handler/accessrequest_test.go index 7517cdd70..93d3d5dad 100644 --- a/pkg/agent/handler/accessrequest_test.go +++ b/pkg/agent/handler/accessrequest_test.go @@ -420,6 +420,7 @@ type mockClient struct { deleteResCalled bool getTeamResult []defs.PlatformTeam getTeamErr error + gotTeamQuery map[string]string t *testing.T } @@ -462,6 +463,7 @@ func (m *mockClient) DeleteResourceInstance(ri v1.Interface) error { } func (m *mockClient) GetTeam(query map[string]string) ([]defs.PlatformTeam, error) { + m.gotTeamQuery = query return m.getTeamResult, m.getTeamErr } diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index 129a39d60..6e5db7e48 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -222,7 +222,7 @@ func (h *managedApplication) resolveTeamName(owner *apiv1.Owner) string { if h.teamClient == nil || owner == nil || owner.ID == "" { return "" } - teams, err := h.teamClient.GetTeam(map[string]string{"query": fmt.Sprintf("id==%q", owner.ID)}) + teams, err := h.teamClient.GetTeam(map[string]string{"query": fmt.Sprintf("guid==%q", owner.ID)}) if err != nil || len(teams) == 0 { return "" } diff --git a/pkg/agent/handler/managedapplication_test.go b/pkg/agent/handler/managedapplication_test.go index 3e1663d34..5df231149 100644 --- a/pkg/agent/handler/managedapplication_test.go +++ b/pkg/agent/handler/managedapplication_test.go @@ -243,6 +243,7 @@ func TestManagedApplicationHandlerCacheMiss(t *testing.T) { ri, _ := app.AsInstance() err := h.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) assert.Nil(t, err) + assert.Equal(t, fmt.Sprintf("guid==%q", team.ID), c.gotTeamQuery["query"]) }) } } From 0751ef1be89d198ff7cd089cc76d1002a35c4409 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Mon, 15 Jun 2026 15:37:43 -0700 Subject: [PATCH 07/22] APIGOV-32943 - get proper placement of team owner --- pkg/agent/handler/managedapplication.go | 6 ++- pkg/agent/handler/managedapplication_test.go | 51 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index 6e5db7e48..6bb87f943 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -76,9 +76,13 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, return nil } + owner := app.Owner + if owner == nil { + owner = app.Marketplace.Resource.Owner + } ma := provManagedApp{ managedAppName: app.Name, - teamName: h.resolveTeamName(app.Owner), + teamName: h.resolveTeamName(owner), data: util.GetAgentDetails(app), consumerOrgID: getConsumerOrgID(app), id: app.Metadata.ID, diff --git a/pkg/agent/handler/managedapplication_test.go b/pkg/agent/handler/managedapplication_test.go index 5df231149..0c0a83478 100644 --- a/pkg/agent/handler/managedapplication_test.go +++ b/pkg/agent/handler/managedapplication_test.go @@ -248,6 +248,57 @@ func TestManagedApplicationHandlerCacheMiss(t *testing.T) { } } +func TestMPOwnerFallback(t *testing.T) { + cases := map[string]struct { + mpOwner *apiv1.Owner + wantTeamName string + }{ + "MP owner resolves from cache when top-level owner is nil": { + mpOwner: &apiv1.Owner{ID: team.ID}, + wantTeamName: teamName, + }, + "both owners nil yields empty team name": { + mpOwner: nil, + wantTeamName: "", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + app := management.ManagedApplication{ + ResourceMeta: managedAppForTest.ResourceMeta, + Owner: nil, + Marketplace: management.ManagedApplicationMarketplace{ + Resource: management.ManagedApplicationMarketplaceResource{ + Owner: tc.mpOwner, + }, + }, + Spec: management.ManagedApplicationSpec{}, + Status: &apiv1.ResourceStatus{Level: prov.Pending.String()}, + } + p := &mockManagedAppProv{ + expectedManagedApp: app.Name, + expectedManagedAppData: util.GetAgentDetails(&app), + expectedTeamName: tc.wantTeamName, + status: mock.MockRequestStatus{ + Status: prov.Success, + Msg: "msg", + Properties: map[string]string{"status_key": "status_val"}, + }, + t: t, + } + c := &mockClient{expectedStatus: prov.Success.String(), t: t} + cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + cm.AddTeam(team) + + h := NewManagedApplicationHandler(p, cm, c) + ri, _ := app.AsInstance() + err := h.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) + assert.Nil(t, err) + assert.Nil(t, c.gotTeamQuery, "team was in startup cache — no API call expected") + }) + } +} + func TestManagedApplicationHandler_wrong_kind(t *testing.T) { cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) c := &mockClient{ From 6c350e1653a7f9728a1d8e7f5475bc99c5912b05 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Tue, 16 Jun 2026 07:18:47 -0700 Subject: [PATCH 08/22] APIGOV-32943 - update to delete policy only after list is cleared --- pkg/authz/oauth/clients/okta.go | 40 ++++++++++++++++++++- pkg/authz/oauth/clients/okta_test.go | 53 +++++++++++++++++++++++++--- pkg/authz/oauth/oktaprovider.go | 29 +++++++++++++++ pkg/authz/oauth/oktaprovider_test.go | 33 ++++++++++++++--- 4 files changed, 146 insertions(+), 9 deletions(-) diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 4f7881084..3d7eaed3a 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -313,7 +313,9 @@ func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope s return nil } -// The policy is never deleted, even when the include list becomes empty. +// RemoveClientFromPolicy removes clientID from the policy's conditions.clients.include list and updates +// the policy via the Okta API. If the client is not present the call is a no-op. The caller is +// responsible for deleting the policy when the include list becomes empty. func (o *Okta) RemoveClientFromPolicy(authServerID string, policy map[string]interface{}, clientID string) error { clientID = strings.TrimSpace(clientID) policyID, _ := policy["id"].(string) @@ -383,6 +385,42 @@ func (o *Okta) DeleteApp(appID string) error { return nil } +// DeactivatePolicy deactivates an Okta authorization server policy. A 404 is treated as success. +// DeactivatePolicy must be called before DeletePolicy. +func (o *Okta) DeactivatePolicy(authServerID, policyID string) error { + endpoint := fmt.Sprintf("%s/api/v1/authorizationServers/%s/policies/%s/lifecycle/deactivate", o.BaseURL, authServerID, policyID) + o.logger.WithField("authServerID", authServerID).WithField("policyID", policyID).WithField("endpoint", endpoint).Trace("deactivating Okta authorization server policy") + resp, err := o.doRequest(coreapi.POST, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusOK, http.StatusNoContent) { + return o.unexpectedStatusError(coreapi.POST, endpoint, resp) + } + return nil +} + +// DeletePolicy deletes an Okta authorization server policy. A 404 is treated as success. +// DeactivatePolicy must be called before this method. +func (o *Okta) DeletePolicy(authServerID, policyID string) error { + endpoint := o.authServerPolicyEndpoint(authServerID, policyID) + o.logger.WithField("authServerID", authServerID).WithField("policyID", policyID).WithField("endpoint", endpoint).Trace("deleting Okta authorization server policy") + resp, err := o.doRequest(coreapi.DELETE, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusNoContent) { + return o.unexpectedStatusError(coreapi.DELETE, endpoint, resp) + } + return nil +} + // FindGroupByName searches Okta for a group with the given name and returns its ID. // Returns ("", nil) when no matching group exists. func (o *Okta) FindGroupByName(groupName string) (string, error) { diff --git a/pkg/authz/oauth/clients/okta_test.go b/pkg/authz/oauth/clients/okta_test.go index cc135b9f1..c9f39623d 100644 --- a/pkg/authz/oauth/clients/okta_test.go +++ b/pkg/authz/oauth/clients/okta_test.go @@ -13,10 +13,10 @@ import ( const ( removeClientID = "remove-me" - errForbidden = "returns error on forbidden" - nilOn200 = "returns nil on 200" - nilOn204 = "returns nil on 204" - notFoundAsSuccess = "treats 404 as success" + errForbidden = "returns error on forbidden" + nilOn200 = "returns nil on 200" + nilOn204 = "returns nil on 204" + notFoundAsSuccess = "treats 404 as success" testPolicyName = "my-policy" testClientID = "client1" testRuleName = "rule-1" @@ -25,6 +25,8 @@ const ( testGroupID = "grp-123" testGroupName = "Marketplace" testAppID = "app-abc" + testAuthServerID = "as1" + testPolicyID = "pol1" ) func newTestOktaClient(t *testing.T, handler http.HandlerFunc) (*Okta, func()) { @@ -237,6 +239,49 @@ func TestOktaRemoveClientFromPolicy(t *testing.T) { } } +func TestOktaDeactivatePolicy(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + nilOn200: {code: http.StatusOK, wantErr: false}, + nilOn204: {code: http.StatusNoContent, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.DeactivatePolicy(testAuthServerID, testPolicyID), tc.wantErr) + }) + } +} + +func TestOktaDeletePolicy(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + nilOn204: {code: http.StatusNoContent, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.DeletePolicy(testAuthServerID, testPolicyID), tc.wantErr) + }) + } +} + func TestOktaDeactivateApp(t *testing.T) { cases := map[string]struct { code int diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index 776f77649..da5b7acbc 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -273,11 +273,40 @@ func (i *okta) handlePerScopePolicyUnassign( if err := oktaClient.RemoveClientFromPolicy(authServerID, policy, clientID); err != nil { return err } + + if err := i.deletePolicyIfEmpty(oktaClient, authServerID, policyName, policy); err != nil { + return err + } } return nil } +func (i *okta) deletePolicyIfEmpty(oktaClient *clients.Okta, authServerID, policyName string, policy map[string]interface{}) error { + conditions, _ := policy["conditions"].(map[string]any) + clientsCond, _ := conditions["clients"].(map[string]any) + include, ok := clientsCond["include"].([]any) + + if !ok || len(include) > 0 { + return nil + } + + policyID, _ := policy["id"].(string) + if policyID == "" { + return nil + } + + i.logger. + WithField("policyID", policyID). + WithField("policyName", policyName). + Debug("client list empty after removal, deleting policy") + + if err := oktaClient.DeactivatePolicy(authServerID, policyID); err != nil { + return err + } + return oktaClient.DeletePolicy(authServerID, policyID) +} + func (i *okta) validateExtraProperties(extraProps map[string]interface{}) error { pkceRequired, _ := extraProps[oktaPKCERequired].(bool) appType, _ := extraProps[oktaApplicationType].(string) diff --git a/pkg/authz/oauth/oktaprovider_test.go b/pkg/authz/oauth/oktaprovider_test.go index 95c8bed5c..95fa39304 100644 --- a/pkg/authz/oauth/oktaprovider_test.go +++ b/pkg/authz/oauth/oktaprovider_test.go @@ -33,8 +33,9 @@ const ( oktaPolicyID = "pol-123" oktaPolicyEndpointByID = "/api/v1/authorizationServers/authorizationID/policies/pol-123" oktaPolicyRulesEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/rules" - oktaDeactivateEndpoint = "/api/v1/apps/app123/lifecycle/deactivate" - oktaDeleteEndpoint = "/api/v1/apps/app123" + oktaDeactivateEndpoint = "/api/v1/apps/app123/lifecycle/deactivate" + oktaDeleteEndpoint = "/api/v1/apps/app123" + oktaPolicyDeactivateEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/lifecycle/deactivate" testGroupName = "Marketplace" testGroupID = "grp-456" oktaGroupsEndpoint = "/api/v1/groups" @@ -279,7 +280,7 @@ func TestOktaPostProcessClientUnregister(t *testing.T) { wantMinCalls map[string]int wantErr bool }{ - "deactivates app, deletes app, removes from policy": { + "deactivates app, deletes app, removes client from shared policy": { scopes: []string{testScope}, grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ @@ -290,7 +291,7 @@ func TestOktaPostProcessClientUnregister(t *testing.T) { w.WriteHeader(http.StatusNoContent) }, http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), - http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{testClientID}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{testClientID, "other-client"}), http.MethodPut + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, }, wantMinCalls: map[string]int{ @@ -301,6 +302,30 @@ func TestOktaPostProcessClientUnregister(t *testing.T) { http.MethodPut + " " + oktaPolicyEndpointByID: 1, }, }, + "policy deactivated and deleted when last client removed": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaDeleteEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{testClientID}), + http.MethodPut + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + http.MethodPost + " " + oktaPolicyDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }, + http.MethodDelete + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }, + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodPut + " " + oktaPolicyEndpointByID: 1, + http.MethodPost + " " + oktaPolicyDeactivateEndpoint: 1, + http.MethodDelete + " " + oktaPolicyEndpointByID: 1, + }, + }, "missing policy during unassign is skipped": { scopes: []string{testScope}, grantType: GrantTypeClientCredentials, From d133f0c843ff44a464cd4f23071cede0a178f9a1 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Tue, 16 Jun 2026 08:31:30 -0700 Subject: [PATCH 09/22] APIGOV-32943 - udpate blacklist to exclude --- pkg/authz/oauth/clients/okta.go | 4 +-- pkg/authz/oauth/provider.go | 16 ++++++------ pkg/authz/oauth/provider_test.go | 38 ++++++++++++++-------------- pkg/config/externalidpconfig.go | 18 ++++++------- pkg/config/externalidpconfig_test.go | 16 ++++++------ 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 3d7eaed3a..0ad026ee2 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -264,7 +264,7 @@ func (o *Okta) CreatePolicy(authServerID, name string, clientID string) (map[str Name: name, Type: "OAUTH_AUTHORIZATION_POLICY", Status: "ACTIVE", - Priority: 1, //SDB - check to see if it defaults to 1 + Priority: 1, Conditions: oktaPolicyConditions{ Clients: &oktaPolicyConditionsClients{Include: []string{clientID}}, }, @@ -300,7 +300,7 @@ func (o *Okta) CreatePolicyRule(authServerID, policyID, name, grantType, scope s Scopes: oktaPolicyRuleConditionScopes{Include: []string{scope}}, }, Actions: oktaPolicyRuleActions{ - Token: oktaPolicyRuleActionToken{AccessTokenLifetimeMinutes: 60}, // SDB - see if this defaults to 60 + Token: oktaPolicyRuleActionToken{AccessTokenLifetimeMinutes: 60}, }, } resp, err := o.doRequest(coreapi.POST, endpoint, req) diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index b0e8fb616..bf443ac8b 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -361,7 +361,7 @@ func (p *provider) GetAuthorizationEndpoint() string { } // GetSupportedScopes - returns the scopes supported by the provider, with any -// configured blacklist removed. Non-Okta providers receive the raw scope list +// configured exclude list removed. Non-Okta providers receive the raw scope list // unchanged. func (p *provider) GetSupportedScopes() []string { if p.authServerMetadata == nil { @@ -371,21 +371,21 @@ func (p *provider) GetSupportedScopes() []string { if p.cfg.GetIDPType() != TypeOkta { return scopes } - bl, ok := p.cfg.(interface{ GetScopeBlacklist() string }) - if !ok || bl.GetScopeBlacklist() == "" { + ex, ok := p.cfg.(interface{ GetScopeExclude() string }) + if !ok || ex.GetScopeExclude() == "" { return scopes } - return filterScopeBlacklist(scopes, bl.GetScopeBlacklist()) + return filterScopeExclude(scopes, ex.GetScopeExclude()) } -// filterScopeBlacklist removes any scope that appears in the comma-separated -// blacklist string. Order of the remaining scopes is preserved. -func filterScopeBlacklist(scopes []string, blacklist string) []string { +// filterScopeExclude removes any scope that appears in the comma-separated +// exclude string. Order of the remaining scopes is preserved. +func filterScopeExclude(scopes []string, exclude string) []string { if len(scopes) == 0 { return scopes } denied := make(map[string]struct{}) - for _, s := range strings.Split(blacklist, ",") { + for _, s := range strings.Split(exclude, ",") { s = strings.TrimSpace(s) if s != "" { denied[s] = struct{}{} diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 793e49329..45bf42acf 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -27,7 +27,7 @@ const ( scopeEmail = "email" scopeWriteAPI = "write:api" - blacklistOpenIDProfile = "openid,profile" + excludeOpenIDProfile = "openid,profile" ) type providerTestCase struct { @@ -658,66 +658,66 @@ func TestNewProviderValidatesOktaGroup(t *testing.T) { } } -func TestFilterScopeBlacklist(t *testing.T) { +func TestFilterScopeExclude(t *testing.T) { cases := map[string]struct { scopes []string - blacklist string + exclude string want []string }{ - "removes blacklisted scopes": { + "removes excluded scopes": { scopes: []string{scopeOpenID, scopeProfile, testScope, scopeWriteAPI}, - blacklist: blacklistOpenIDProfile, + exclude: excludeOpenIDProfile, want: []string{testScope, scopeWriteAPI}, }, - "empty blacklist returns all scopes": { + "empty exclude returns all scopes": { scopes: []string{scopeOpenID, testScope}, - blacklist: "", + exclude: "", want: []string{scopeOpenID, testScope}, }, - "blacklist with whitespace is trimmed": { + "exclude with whitespace is trimmed": { scopes: []string{scopeOpenID, testScope}, - blacklist: " openid , profile ", + exclude: " openid , profile ", want: []string{testScope}, }, - "no scopes match blacklist returns all": { + "no scopes match exclude returns all": { scopes: []string{testScope, scopeWriteAPI}, - blacklist: blacklistOpenIDProfile, + exclude: excludeOpenIDProfile, want: []string{testScope, scopeWriteAPI}, }, - "all scopes blacklisted returns empty": { + "all scopes excludeed returns empty": { scopes: []string{scopeOpenID, scopeProfile}, - blacklist: blacklistOpenIDProfile, + exclude: excludeOpenIDProfile, want: []string{}, }, "nil scopes returns nil": { scopes: nil, - blacklist: scopeOpenID, + exclude: scopeOpenID, want: nil, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - got := filterScopeBlacklist(tc.scopes, tc.blacklist) + got := filterScopeExclude(tc.scopes, tc.exclude) assert.Equal(t, tc.want, got) }) } } -func TestGetSupportedScopesAppliesBlacklist(t *testing.T) { +func TestGetSupportedScopesAppliesExclude(t *testing.T) { cases := map[string]struct { cfg config.IDPConfig rawScopes []string wantScopes []string }{ - "Okta config with blacklist filters scopes": { + "Okta config with exclude filters scopes": { cfg: &config.IDPConfiguration{ Type: TypeOkta, - Okta: &config.OktaIDPConfiguration{ScopeBlacklist: "openid,profile"}, + Okta: &config.OktaIDPConfiguration{ScopeExclude: "openid,profile"}, }, rawScopes: []string{"openid", "profile", "read:api"}, wantScopes: []string{"read:api"}, }, - "Okta config with default blacklist filters defaults": { + "Okta config with default exclude filters defaults": { cfg: &config.IDPConfiguration{Type: TypeOkta, Okta: &config.OktaIDPConfiguration{}}, rawScopes: []string{"openid", "profile", "email", "read:api"}, wantScopes: []string{"read:api"}, diff --git a/pkg/config/externalidpconfig.go b/pkg/config/externalidpconfig.go index 1271f3f6e..4bcbd19ef 100644 --- a/pkg/config/externalidpconfig.go +++ b/pkg/config/externalidpconfig.go @@ -39,7 +39,7 @@ const ( fldOktaAppNameTemplate = "okta.appname_template" fldOktaPolicyNameTemplate = "okta.policyname_template" fldOktaScopeSources = "okta.scope_sources" - fldOktaScopeBlacklist = "okta.scope_blacklist" + fldOktaScopeExclude = "okta.scope_exclude" fldExtraProperties = "extraProperties" fldRequestHeaders = "requestHeaders" fldQueryParams = "queryParams" @@ -68,7 +68,7 @@ const ( defaultOktaAppNameTemplate = OktaPlaceholderMPApplicationName + "-" + OktaPlaceholderOwningTeam + "-" + OktaPlaceholderCredentialName defaultOktaPolicyNameTemplate = OktaPlaceholderScope + "-" + OktaPlaceholderOAuthFlow defaultOktaScopeSources = "swagger,gateway,okta" - defaultOktaScopeBlacklist = "openid,profile,email,address,phone,offline_access" + defaultOktaScopeExclude = "openid,profile,email,address,phone,offline_access" ) var configProperties = []string{ @@ -80,7 +80,7 @@ var configProperties = []string{ fldOktaAppNameTemplate, fldOktaPolicyNameTemplate, fldOktaScopeSources, - fldOktaScopeBlacklist, + fldOktaScopeExclude, fldExtraProperties, fldRequestHeaders, fldQueryParams, @@ -300,7 +300,7 @@ type OktaIDPConfiguration struct { AppNameTemplate string `json:"appname_template,omitempty"` PolicyNameTemplate string `json:"policyname_template,omitempty"` ScopeSources string `json:"scope_sources,omitempty"` - ScopeBlacklist string `json:"scope_blacklist,omitempty"` + ScopeExclude string `json:"scope_exclude,omitempty"` } // IDPConfiguration - Structure to hold the IdP provider config @@ -370,12 +370,12 @@ func (i *IDPConfiguration) GetScopeSources() string { return i.Okta.ScopeSources } -// GetScopeBlacklist - comma-separated scopes excluded from the Marketplace UI; consumed by the v7 agent. -func (i *IDPConfiguration) GetScopeBlacklist() string { - if i.Okta == nil || i.Okta.ScopeBlacklist == "" { - return defaultOktaScopeBlacklist +// GetScopeExclude - comma-separated scopes excluded from the Marketplace UI; consumed by the v7 agent. +func (i *IDPConfiguration) GetScopeExclude() string { + if i.Okta == nil || i.Okta.ScopeExclude == "" { + return defaultOktaScopeExclude } - return i.Okta.ScopeBlacklist + return i.Okta.ScopeExclude } // GetOktaGroup - name of the Okta group that newly registered apps are added to. diff --git a/pkg/config/externalidpconfig_test.go b/pkg/config/externalidpconfig_test.go index 4a75939ce..5edd7b3fe 100644 --- a/pkg/config/externalidpconfig_test.go +++ b/pkg/config/externalidpconfig_test.go @@ -31,7 +31,7 @@ const ( envKeyIDPOktaAppNameTemplate = "AGENTFEATURES_IDP_OKTA_APPNAME_TEMPLATE_1" envKeyIDPOktaPolicyNameTemplate = "AGENTFEATURES_IDP_OKTA_POLICYNAME_TEMPLATE_1" envKeyIDPOktaScopeSources = "AGENTFEATURES_IDP_OKTA_SCOPE_SOURCES_1" - envKeyIDPOktaScopeBlacklist = "AGENTFEATURES_IDP_OKTA_SCOPE_BLACKLIST_1" + envKeyIDPOktaScopeExclude = "AGENTFEATURES_IDP_OKTA_SCOPE_EXCLUDE_1" ) func setEnvVars(t *testing.T, env map[string]string) { @@ -169,8 +169,8 @@ func TestExternalIDPConfig(t *testing.T) { "okta scope sources config via env var": { envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeSources: "swagger,okta"}), }, - "okta scope blacklist config via env var": { - envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeBlacklist: "openid,profile"}), + "okta scope exclude config via env var": { + envNames: mergeEnv(baseOkta, map[string]string{envKeyIDPOktaScopeExclude: "openid,profile"}), }, "client auth config with no clientid/secret": { envNames: map[string]string{ @@ -216,7 +216,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { wantAppTemplate string wantPolicyTemplate string wantScopeSources string - wantScopeBlacklist string + wantScopeExclude string wantGroup string }{ "nil okta config returns all defaults": { @@ -224,7 +224,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { wantAppTemplate: defaultOktaAppNameTemplate, wantPolicyTemplate: defaultOktaPolicyNameTemplate, wantScopeSources: defaultOktaScopeSources, - wantScopeBlacklist: defaultOktaScopeBlacklist, + wantScopeExclude: defaultOktaScopeExclude, wantGroup: "", }, "configured values are returned": { @@ -233,12 +233,12 @@ func TestOktaIDPConfigGetters(t *testing.T) { AppNameTemplate: "my-" + OktaPlaceholderCredentialName, PolicyNameTemplate: OktaPlaceholderScope, ScopeSources: "swagger", - ScopeBlacklist: "openid", + ScopeExclude: "openid", }}, wantAppTemplate: "my-" + OktaPlaceholderCredentialName, wantPolicyTemplate: OktaPlaceholderScope, wantScopeSources: "swagger", - wantScopeBlacklist: "openid", + wantScopeExclude: "openid", wantGroup: "Marketplace", }, } @@ -247,7 +247,7 @@ func TestOktaIDPConfigGetters(t *testing.T) { assert.Equal(t, tc.wantAppTemplate, tc.cfg.GetAppNameTemplate()) assert.Equal(t, tc.wantPolicyTemplate, tc.cfg.GetPolicyNameTemplate()) assert.Equal(t, tc.wantScopeSources, tc.cfg.GetScopeSources()) - assert.Equal(t, tc.wantScopeBlacklist, tc.cfg.GetScopeBlacklist()) + assert.Equal(t, tc.wantScopeExclude, tc.cfg.GetScopeExclude()) assert.Equal(t, tc.wantGroup, tc.cfg.GetOktaGroup()) }) } From bc063c309b344d94c321b714e8ea235056955656 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Tue, 23 Jun 2026 15:42:49 +0300 Subject: [PATCH 10/22] idp changes --- pkg/agent/handler/credential.go | 14 + pkg/agent/handler/idpclientlifecycle.go | 117 ++++++ pkg/agent/handler/idpclientlifecycle_test.go | 355 +++++++++++++++++++ pkg/agent/handler/managedapplication.go | 25 +- pkg/agent/provisioning.go | 4 +- 5 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 pkg/agent/handler/idpclientlifecycle.go create mode 100644 pkg/agent/handler/idpclientlifecycle_test.go diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index 55772aed9..ec2995137 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -342,6 +342,20 @@ func (h *credentials) provisionPostProcess(status prov.RequestStatus, credential isExternal := isExternalCredential(cred) if status.GetStatus() == prov.Success { credentialData := h.getProvisionedCredentialData(provCreds, credentialData) + if provCreds.IsIDPCredential() && !isExternal { + if err := persistIDPClientOnManagedApplication( + fieldLogger, + h.client, + app, + provCreds.GetIDPCredentialData().GetClientID(), + provCreds.GetIDPProvider().GetTokenEndpoint(), + ); err != nil { + status = prov.NewRequestStatusBuilder(). + SetMessage(fmt.Sprintf("error storing IDP client reference on managed application: %s", err.Error())). + SetCurrentStatusReasons(cred.Status.Reasons). + Failed() + } + } if credentialData != nil { if !isExternal { sec := app.Spec.Security diff --git a/pkg/agent/handler/idpclientlifecycle.go b/pkg/agent/handler/idpclientlifecycle.go new file mode 100644 index 000000000..98e6d569e --- /dev/null +++ b/pkg/agent/handler/idpclientlifecycle.go @@ -0,0 +1,117 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/authz/oauth" + "github.com/Axway/agent-sdk/pkg/util" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +const ( + oktaClientIDsAgentDetail = "oktaClientIDs" + tokenURLAgentDetail = "tokenURL" +) + +// persistIDPClientOnManagedApplication stores IDP client IDs on the ManagedApplication x-agent-details +// so they can be cleaned up if the app is deleted before credential-level cleanup runs. +func persistIDPClientOnManagedApplication(logger log.FieldLogger, client client, app *management.ManagedApplication, clientID, tokenURL string) error { + if app == nil || clientID == "" { + return nil + } + + details := util.GetAgentDetails(app) + if details == nil { + details = make(map[string]interface{}) + } + + existing := extractClientIDs(details[oktaClientIDsAgentDetail]) + for _, id := range existing { + if id == clientID { + return nil + } + } + existing = append(existing, clientID) + + details[oktaClientIDsAgentDetail] = existing + if tokenURL != "" { + details[tokenURLAgentDetail] = tokenURL + } + + if err := client.CreateSubResource(app.ResourceMeta, map[string]interface{}{defs.XAgentDetails: details}); err != nil { + return fmt.Errorf("could not persist IDP client reference on managed application %s: %w", app.Name, err) + } + + if logger != nil { + logger.WithField("clientID", clientID).Trace("persisted IDP client reference on managed application") + } + + return nil +} + +// cleanupManagedApplicationIDPClients removes any tracked IDP clients for a ManagedApplication. +func cleanupManagedApplicationIDPClients(ctx context.Context, logger log.FieldLogger, registry oauth.IdPRegistry, app *management.ManagedApplication) error { + if registry == nil || app == nil { + return nil + } + + details := util.GetAgentDetails(app) + if details == nil { + return nil + } + + clientIDs := extractClientIDs(details[oktaClientIDsAgentDetail]) + if len(clientIDs) == 0 { + return nil + } + + tokenURL := util.ToString(details[tokenURLAgentDetail]) + if tokenURL == "" { + logger.Warn("no tokenURL in managed application x-agent-details, skipping IDP app cleanup") + return nil + } + + provider, err := registry.GetProviderByTokenEndpoint(ctx, tokenURL) + if err != nil || provider == nil { + logger.WithField("tokenURL", tokenURL).Warn("no IDP provider registered for tokenURL, skipping IDP app cleanup") + return nil + } + + for _, clientID := range clientIDs { + if err := provider.UnregisterClient(clientID, "", "", nil, ""); err != nil { + return fmt.Errorf("cleanupManagedApplicationIDPClients: failed for client %s: %w", clientID, err) + } + logger.WithField("clientID", clientID).Info("IDP client unregistered") + } + + return nil +} + +func extractClientIDs(raw interface{}) []string { + switch v := raw.(type) { + case []string: + return append([]string(nil), v...) + case []interface{}: + ids := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + ids = append(ids, s) + } + } + return ids + case string: + if v == "" { + return nil + } + var ids []string + if err := json.Unmarshal([]byte(v), &ids); err == nil { + return ids + } + } + + return nil +} diff --git a/pkg/agent/handler/idpclientlifecycle_test.go b/pkg/agent/handler/idpclientlifecycle_test.go new file mode 100644 index 000000000..2ce4c785a --- /dev/null +++ b/pkg/agent/handler/idpclientlifecycle_test.go @@ -0,0 +1,355 @@ +package handler + +import ( + "context" + "errors" + "testing" + "time" + + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/authz/oauth" + corecfg "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/util" + "github.com/Axway/agent-sdk/pkg/util/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type lifecycleClientMock struct { + createSubCalled bool + createSubErr error + lastSubs map[string]interface{} +} + +func (m *lifecycleClientMock) GetResource(_ string) (*apiv1.ResourceInstance, error) { + return nil, nil +} + +func (m *lifecycleClientMock) UpdateResourceFinalizer(_ *apiv1.ResourceInstance, _, _ string, _ bool) (*apiv1.ResourceInstance, error) { + return nil, nil +} + +func (m *lifecycleClientMock) CreateSubResource(_ apiv1.ResourceMeta, subs map[string]interface{}) error { + m.createSubCalled = true + m.lastSubs = subs + return m.createSubErr +} + +func (m *lifecycleClientMock) UpdateResourceInstance(_ apiv1.Interface) (*apiv1.ResourceInstance, error) { + return nil, nil +} + +func (m *lifecycleClientMock) DeleteResourceInstance(_ apiv1.Interface) error { + return nil +} + +type idpProviderMock struct { + unregisterCalls []string + unregisterErr error +} + +func (m *idpProviderMock) GetName() string { return "mock" } + +func (m *idpProviderMock) GetTitle() string { return "mock" } + +func (m *idpProviderMock) GetIssuer() string { return "issuer" } + +func (m *idpProviderMock) GetTokenEndpoint() string { return "https://idp.example.com/token" } + +func (m *idpProviderMock) GetMTLSTokenEndpoint() string { return "" } + +func (m *idpProviderMock) GetAuthorizationEndpoint() string { return "https://idp.example.com/auth" } + +func (m *idpProviderMock) GetSupportedScopes() []string { return nil } + +func (m *idpProviderMock) GetSupportedGrantTypes() []string { return nil } + +func (m *idpProviderMock) GetSupportedTokenAuthMethods() []string { return nil } + +func (m *idpProviderMock) GetSupportedResponseMethod() []string { return nil } + +func (m *idpProviderMock) RegisterClient(_ oauth.ClientMetadata) (oauth.ClientMetadata, error) { + return nil, nil +} + +func (m *idpProviderMock) UnregisterClient(clientID, _, _ string, _ []string, _ string) error { + m.unregisterCalls = append(m.unregisterCalls, clientID) + return m.unregisterErr +} + +func (m *idpProviderMock) Validate() error { return nil } + +func (m *idpProviderMock) GetConfig() corecfg.IDPConfig { return nil } + +func (m *idpProviderMock) GetMetadata() *oauth.AuthorizationServerMetadata { return nil } + +func (m *idpProviderMock) GetIDPResourceName() string { return "" } + +type idpRegistryMock struct { + provider oauth.Provider + err error + called bool + gotTokenURL string +} + +func newManagedAppForLifecycle(name string) *management.ManagedApplication { + return management.NewManagedApplication(name, "env") +} + +func (m *idpRegistryMock) RegisterProvider(_ context.Context, _ corecfg.IDPConfig, _ corecfg.TLSConfig, _ string, _ time.Duration) error { + return nil +} + +func (m *idpRegistryMock) RegisterProviderWithMetadata(_ context.Context, _ corecfg.IDPConfig, _ *oauth.AuthorizationServerMetadata, _ corecfg.TLSConfig, _ string, _ time.Duration) error { + return nil +} + +func (m *idpRegistryMock) UnregisterProvider(_ context.Context, _ oauth.Provider) error { return nil } + +func (m *idpRegistryMock) GetProviderByName(_ context.Context, _ string, _ ...oauth.ConfigOption) (oauth.Provider, error) { + return nil, nil +} + +func (m *idpRegistryMock) GetProviderByIssuer(_ context.Context, _ string, _ ...oauth.ConfigOption) (oauth.Provider, error) { + return nil, nil +} + +func (m *idpRegistryMock) GetProviderByTokenEndpoint(_ context.Context, tokenEndpoint string, _ ...oauth.ConfigOption) (oauth.Provider, error) { + m.called = true + m.gotTokenURL = tokenEndpoint + return m.provider, m.err +} + +func (m *idpRegistryMock) GetProviderByAuthorizationEndpoint(_ context.Context, _ string, _ ...oauth.ConfigOption) (oauth.Provider, error) { + return nil, nil +} + +func (m *idpRegistryMock) GetProviderByMetadataURL(_ context.Context, _ string, _ ...oauth.ConfigOption) (oauth.Provider, error) { + return nil, nil +} + +func (m *idpRegistryMock) GetIDPResourceName(_ string) (string, bool) { return "", false } + +func TestExtractClientIDs(t *testing.T) { + tests := map[string]struct { + raw interface{} + expect []string + nilResp bool + }{ + "from []string": { + raw: []string{"a", "b"}, + expect: []string{"a", "b"}, + }, + "from []interface{}": { + raw: []interface{}{"a", 1, "b"}, + expect: []string{"a", "b"}, + }, + "from JSON string": { + raw: `["a","b"]`, + expect: []string{"a", "b"}, + }, + "invalid JSON string": { + raw: `not-json`, + nilResp: true, + }, + "nil input": { + raw: nil, + nilResp: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := extractClientIDs(tt.raw) + if tt.nilResp { + assert.Nil(t, got) + return + } + assert.Equal(t, tt.expect, got) + }) + } +} + +func TestPersistIDPClientOnManagedApplication(t *testing.T) { + tests := map[string]struct { + app *management.ManagedApplication + clientID string + tokenURL string + createSubErr error + wantErr bool + wantCall bool + wantIDs []string + wantTokenURL string + }{ + "nil app": { + clientID: "client-1", + tokenURL: "https://idp.example.com/token", + }, + "empty client ID": { + app: newManagedAppForLifecycle("app"), + tokenURL: "https://idp.example.com/token", + }, + "first client is stored": { + app: newManagedAppForLifecycle("app"), + clientID: "client-1", + tokenURL: "https://idp.example.com/token", + wantCall: true, + wantIDs: []string{"client-1"}, + wantTokenURL: "https://idp.example.com/token", + }, + "duplicate client is ignored": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{oktaClientIDsAgentDetail: []interface{}{"client-1"}}) + return a + }(), + clientID: "client-1", + tokenURL: "https://idp.example.com/token", + }, + "second client is appended": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{oktaClientIDsAgentDetail: []interface{}{"client-1"}}) + return a + }(), + clientID: "client-2", + tokenURL: "https://idp.example.com/token", + wantCall: true, + wantIDs: []string{"client-1", "client-2"}, + wantTokenURL: "https://idp.example.com/token", + }, + "create subresource error is returned": { + app: newManagedAppForLifecycle("app"), + clientID: "client-1", + tokenURL: "https://idp.example.com/token", + createSubErr: errors.New("write failed"), + wantErr: true, + wantCall: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &lifecycleClientMock{createSubErr: tt.createSubErr} + err := persistIDPClientOnManagedApplication(log.NewFieldLogger(), c, tt.app, tt.clientID, tt.tokenURL) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantCall, c.createSubCalled) + + if tt.wantCall && !tt.wantErr { + details, ok := c.lastSubs[defs.XAgentDetails].(map[string]interface{}) + require.True(t, ok) + ids, ok := details[oktaClientIDsAgentDetail].([]string) + require.True(t, ok) + assert.Equal(t, tt.wantIDs, ids) + assert.Equal(t, tt.wantTokenURL, details[tokenURLAgentDetail]) + } + }) + } +} + +func TestCleanupManagedApplicationIDPClients(t *testing.T) { + tests := map[string]struct { + app *management.ManagedApplication + registry *idpRegistryMock + wantErr bool + wantRegistryLookup bool + wantUnregisterCalls []string + }{ + "nil registry": { + app: newManagedAppForLifecycle("app"), + }, + "nil app": { + registry: &idpRegistryMock{}, + }, + "no client IDs": { + app: newManagedAppForLifecycle("app"), + registry: &idpRegistryMock{}, + }, + "missing token URL skips cleanup": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{oktaClientIDsAgentDetail: []interface{}{"client-1"}}) + return a + }(), + registry: &idpRegistryMock{}, + }, + "provider lookup error is ignored": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{ + oktaClientIDsAgentDetail: []interface{}{"client-1"}, + tokenURLAgentDetail: "https://idp.example.com/token", + }) + return a + }(), + registry: &idpRegistryMock{err: errors.New("not found")}, + wantRegistryLookup: true, + }, + "provider is nil is ignored": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{ + oktaClientIDsAgentDetail: []interface{}{"client-1"}, + tokenURLAgentDetail: "https://idp.example.com/token", + }) + return a + }(), + registry: &idpRegistryMock{}, + wantRegistryLookup: true, + }, + "unregister error is returned": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{ + oktaClientIDsAgentDetail: []string{"client-1"}, + tokenURLAgentDetail: "https://idp.example.com/token", + }) + return a + }(), + registry: &idpRegistryMock{provider: &idpProviderMock{unregisterErr: errors.New("unregister failed")}}, + wantRegistryLookup: true, + wantErr: true, + wantUnregisterCalls: []string{"client-1"}, + }, + "success unregisters all IDs": { + app: func() *management.ManagedApplication { + a := newManagedAppForLifecycle("app") + util.SetAgentDetails(a, map[string]interface{}{ + oktaClientIDsAgentDetail: `["client-1","client-2"]`, + tokenURLAgentDetail: "https://idp.example.com/token", + }) + return a + }(), + registry: &idpRegistryMock{provider: &idpProviderMock{}}, + wantRegistryLookup: true, + wantUnregisterCalls: []string{"client-1", "client-2"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + err := cleanupManagedApplicationIDPClients(context.Background(), log.NewFieldLogger(), tt.registry, tt.app) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + if tt.registry != nil { + assert.Equal(t, tt.wantRegistryLookup, tt.registry.called) + } + + if tt.registry != nil && tt.registry.provider != nil { + if p, ok := tt.registry.provider.(*idpProviderMock); ok { + assert.Equal(t, tt.wantUnregisterCalls, p.unregisterCalls) + } + } + }) + } +} diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index 6bb87f943..be337d666 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -11,6 +11,7 @@ import ( management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" prov "github.com/Axway/agent-sdk/pkg/apic/provisioning" + "github.com/Axway/agent-sdk/pkg/authz/oauth" "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" ) @@ -24,12 +25,13 @@ type teamFetcher interface { } type managedApplication struct { + idpRegistry oauth.IdPRegistry marketplaceHandler - prov prov.ApplicationProvisioner - cache agentcache.Manager - client client - teamClient teamFetcher - retryCount int + prov prov.ApplicationProvisioner + cache agentcache.Manager + client client + teamClient teamFetcher + retryCount int } func WithManagedAppRetryCount(rc int) func(c *managedApplication) { @@ -38,6 +40,12 @@ func WithManagedAppRetryCount(rc int) func(c *managedApplication) { } } +func WithManagedAppIDPRegistry(registry oauth.IdPRegistry) func(c *managedApplication) { + return func(c *managedApplication) { + c.idpRegistry = registry + } +} + // NewManagedApplicationHandler creates a Handler for Credentials func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentcache.Manager, client client, opts ...func(c *managedApplication)) Handler { ma := &managedApplication{ @@ -159,8 +167,13 @@ func (h *managedApplication) provision(pma provManagedApp) prov.RequestStatus { func (h *managedApplication) onDeleting(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) { log := getLoggerFromContext(ctx) + if err := cleanupManagedApplicationIDPClients(ctx, log, h.idpRegistry, app); err != nil { + log.WithError(err).Error("error cleaning up managed application IDP clients") + h.onError(app, err) + h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + return + } status := h.prov.ApplicationRequestDeprovision(pma) - if status.GetStatus() == prov.Success { ri, _ := app.AsInstance() h.client.UpdateResourceFinalizer(ri, maFinalizer, "", false) diff --git a/pkg/agent/provisioning.go b/pkg/agent/provisioning.go index d7b8b644c..6d93eee97 100644 --- a/pkg/agent/provisioning.go +++ b/pkg/agent/provisioning.go @@ -604,10 +604,12 @@ func CleanApplicationProfileDefinition(name string) error { // application provisioner func registerApplicationProvisioner(provisioner interface{}) { if appProv, ok := provisioner.(provisioning.ApplicationProvisioner); ok { + registry := oauth.NewIdpRegistry(oauth.WithProviderRegistry(GetAuthProviderRegistry())) agent.proxyResourceHandler.RegisterTargetHandler( "managedappHandler", handler.NewManagedApplicationHandler(appProv, agent.cacheManager, agent.apicClient, - handler.WithManagedAppRetryCount(agent.cfg.GetProvisioningRetryCount())), + handler.WithManagedAppRetryCount(agent.cfg.GetProvisioningRetryCount()), + handler.WithManagedAppIDPRegistry(registry)), ) } } From 5cdd3a126e9ba7b6dc02538d0650bad28408f57f Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Wed, 24 Jun 2026 11:43:03 +0300 Subject: [PATCH 11/22] persist IDPClient on managedApp straight after RegisterClient() --- pkg/agent/handler/credential.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index ec2995137..823d1603d 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -278,6 +278,14 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential h.onError(ctx, cred, err) return cred } + if err := persistIDPClientOnManagedApplication(fieldLogger, h.client, app, + provCreds.GetIDPCredentialData().GetClientID(), + provCreds.GetIDPProvider().GetTokenEndpoint(), + ); err != nil { + logger.WithError(err).Error("error storing IDP client reference on managed application") + h.onError(ctx, cred, err) + return cred + } } status, credentialData := h.provision(provCreds) @@ -342,20 +350,6 @@ func (h *credentials) provisionPostProcess(status prov.RequestStatus, credential isExternal := isExternalCredential(cred) if status.GetStatus() == prov.Success { credentialData := h.getProvisionedCredentialData(provCreds, credentialData) - if provCreds.IsIDPCredential() && !isExternal { - if err := persistIDPClientOnManagedApplication( - fieldLogger, - h.client, - app, - provCreds.GetIDPCredentialData().GetClientID(), - provCreds.GetIDPProvider().GetTokenEndpoint(), - ); err != nil { - status = prov.NewRequestStatusBuilder(). - SetMessage(fmt.Sprintf("error storing IDP client reference on managed application: %s", err.Error())). - SetCurrentStatusReasons(cred.Status.Reasons). - Failed() - } - } if credentialData != nil { if !isExternal { sec := app.Spec.Security @@ -474,6 +468,14 @@ func (h *credentials) onUpdates(ctx context.Context, cred *management.Credential h.onError(ctx, cred, err) return cred } + if err := persistIDPClientOnManagedApplication(fieldLogger, h.client, app, + provCreds.GetIDPCredentialData().GetClientID(), + provCreds.GetIDPProvider().GetTokenEndpoint(), + ); err != nil { + logger.WithError(err).Error("error storing IDP client reference on managed application") + h.onError(ctx, cred, err) + return cred + } } status, credentialData := h.prov.CredentialUpdate(provCreds) From dafd65fdf192df27ed367b6fc3b5a95773921d66 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Wed, 24 Jun 2026 11:46:32 +0300 Subject: [PATCH 12/22] fix tests --- pkg/agent/handler/credential_test.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pkg/agent/handler/credential_test.go b/pkg/agent/handler/credential_test.go index d5c53713d..b9c2edf1c 100644 --- a/pkg/agent/handler/credential_test.go +++ b/pkg/agent/handler/credential_test.go @@ -728,7 +728,7 @@ type mockCredProv struct { func (m *mockCredProv) CredentialProvision(cr prov.CredentialRequest) (status prov.RequestStatus, credentails prov.Credential) { m.expectedProvType = provision v := cr.(*provCreds) - assert.Equal(m.t, m.expectedAppDetails, v.appDetails) + assertMapContains(m.t, v.appDetails, m.expectedAppDetails) assert.Equal(m.t, m.expectedCredDetails, v.credDetails) assert.Equal(m.t, m.expectedManagedApp, v.managedApp) assert.Equal(m.t, m.expectedCredType, v.credType) @@ -738,7 +738,7 @@ func (m *mockCredProv) CredentialProvision(cr prov.CredentialRequest) (status pr func (m *mockCredProv) CredentialDeprovision(cr prov.CredentialRequest) (status prov.RequestStatus) { m.expectedProvType = deprovision v := cr.(*provCreds) - assert.Equal(m.t, m.expectedAppDetails, v.appDetails) + assertMapContains(m.t, v.appDetails, m.expectedAppDetails) assert.Equal(m.t, m.expectedCredDetails, v.credDetails) assert.Equal(m.t, m.expectedManagedApp, v.managedApp) assert.Equal(m.t, m.expectedCredType, v.credType) @@ -748,13 +748,20 @@ func (m *mockCredProv) CredentialDeprovision(cr prov.CredentialRequest) (status func (m *mockCredProv) CredentialUpdate(cr prov.CredentialRequest) (status prov.RequestStatus, credentails prov.Credential) { m.expectedProvType = update v := cr.(*provCreds) - assert.Equal(m.t, m.expectedAppDetails, v.appDetails) + assertMapContains(m.t, v.appDetails, m.expectedAppDetails) assert.Equal(m.t, m.expectedCredDetails, v.credDetails) assert.Equal(m.t, m.expectedManagedApp, v.managedApp) assert.Equal(m.t, m.expectedCredType, v.credType) return m.expectedStatus, &mockProvCredential{} } +func assertMapContains(t *testing.T, actual, expected map[string]interface{}) { + t.Helper() + for k, v := range expected { + assert.Equal(t, v, actual[k], "expected map to contain key %q", k) + } +} + func (m *mockCredProv) GetIgnoredCredentialTypes() []string { return m.ignoredCredTypeNames } @@ -1069,9 +1076,9 @@ func TestExternalCredentialOnPending(t *testing.T) { } p := &mockExternalCredProv{ - t: t, + t: t, expectedStatus: expectedStatus, - provisionErr: tc.provisionErr, + provisionErr: tc.provisionErr, } c := &credClient{ From ec038cdcd1232a7c24cddbc712bb33e17faa8f33 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Wed, 24 Jun 2026 14:04:25 +0300 Subject: [PATCH 13/22] change unregister logic --- pkg/authz/oauth/provider.go | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index bf443ac8b..2847edc1a 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -645,35 +645,28 @@ func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI } // Continue to OAuth client deletion even on cleanup error; leave no active clients behind. - cleanupErr := p.runPostUnregisterHook(clientID, scopes, grantType) - unregisterErr := p.attemptUnregisterAll(logger, clientID, registrationClientURI, authPrefix, accessToken) - - if unregisterErr == nil { - logger.Info("unregistered client") + if p.cfg.GetIDPType() == TypeOkta { + cleanupErr := p.runPostUnregisterHook(clientID, scopes, grantType) + if cleanupErr != nil { + return fmt.Errorf("failed to complete provider cleanup after client unregistration. Manual cleanup in Okta may be required: %w", cleanupErr) + } } - switch { - case cleanupErr != nil && unregisterErr != nil: - return fmt.Errorf("failed to fully remove the Okta client. Provider cleanup failed and OAuth client deletion failed. cleanup error: %v; delete error: %w", cleanupErr, unregisterErr) - case unregisterErr != nil: + // if not Okta, attempt to unregister everything depending on the provided values + unregisterErr := p.attemptUnregisterAll(logger, clientID, registrationClientURI, authPrefix, accessToken) + if unregisterErr != nil { return fmt.Errorf("failed to delete the OAuth client from the identity provider: %w", unregisterErr) - case cleanupErr != nil: - return fmt.Errorf("failed to complete provider cleanup after client unregistration. Manual cleanup in Okta may be required: %w", cleanupErr) - default: - return nil } + logger.Info("successfully unregistered client") + return nil } func (p *provider) runPostUnregisterHook(clientID string, scopes []string, grantType string) error { - if p.cfg.GetIDPType() != TypeOkta { - return nil - } return p.idpType.postProcessClientUnregister(clientID, p.cfg, p.apiClient, scopes, grantType) } // attemptUnregisterAll tries unregistering with the registration URI, the standard // registration endpoint (base + /clientID) and finally as a query-parameter. - func (p *provider) attemptUnregisterAll(logger log.FieldLogger, clientID, registrationClientURI, authPrefix, accessToken string) error { var err error From 0511b01e0a72bf08d542e5cc1a3d147e21af6ce7 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Wed, 24 Jun 2026 14:21:00 +0300 Subject: [PATCH 14/22] add return --- pkg/authz/oauth/provider.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index 2847edc1a..010a6e0ef 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -650,6 +650,7 @@ func (p *provider) UnregisterClient(clientID, accessToken, registrationClientURI if cleanupErr != nil { return fmt.Errorf("failed to complete provider cleanup after client unregistration. Manual cleanup in Okta may be required: %w", cleanupErr) } + return nil } // if not Okta, attempt to unregister everything depending on the provided values From 4c9db8a73057496e324784f514a9315927e1f431 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Thu, 25 Jun 2026 17:12:34 +0300 Subject: [PATCH 15/22] changes --- pkg/agent/handler/managedapplication.go | 1 + pkg/apic/provisioning/definitions.go | 3 +- pkg/apic/provisioning/idp/provisioner.go | 6 +-- pkg/apic/provisioning/idp/provisioner_test.go | 30 ++++++------- pkg/authz/oauth/mockidpserver.go | 4 -- pkg/authz/oauth/provider.go | 2 +- pkg/authz/oauth/provider_test.go | 42 +++++++++---------- 7 files changed, 41 insertions(+), 47 deletions(-) diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index be337d666..a35c24ba9 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -114,6 +114,7 @@ func (h *managedApplication) onPending(ctx context.Context, app *management.Mana status := h.provision(pma) app.Status = prov.NewStatusReason(status) + util.SetAgentDetailsKey(app, prov.AgentDetailTeamName, pma.GetTeamName()) details := util.MergeMapStringString(util.GetAgentDetailStrings(app), status.GetProperties()) util.SetAgentDetails(app, util.MapStringStringToMapStringInterface(details)) diff --git a/pkg/apic/provisioning/definitions.go b/pkg/apic/provisioning/definitions.go index 6f556407a..a3a516789 100644 --- a/pkg/apic/provisioning/definitions.go +++ b/pkg/apic/provisioning/definitions.go @@ -29,7 +29,8 @@ const ( OauthTLSAuthSANURI = "tlsClientAuthSanURI" OauthRegistrationToken = "registration" - IDPTokenURL = "idpTokenURL" + IDPTokenURL = "idpTokenURL" + AgentDetailTeamName = "teamName" APIKey = "apiKey" diff --git a/pkg/apic/provisioning/idp/provisioner.go b/pkg/apic/provisioning/idp/provisioner.go index a6a9f750d..4a80c69c0 100644 --- a/pkg/apic/provisioning/idp/provisioner.go +++ b/pkg/apic/provisioning/idp/provisioner.go @@ -13,9 +13,7 @@ import ( ) const ( - IDPTokenURL = "idpTokenURL" registrationClientURIKey = "registrationClientURI" - agentDetailTeamName = "teamName" ) type Provisioner interface { @@ -50,7 +48,7 @@ func NewProvisioner(ctx context.Context, idpRegistry oauth.IdPRegistry, app *man p.provisioningMode = credential.Spec.Provision.Mode } - idpTokenURL, ok := p.credential.Spec.Data[IDPTokenURL].(string) + idpTokenURL, ok := p.credential.Spec.Data[provisioning.IDPTokenURL].(string) if ok && idpRegistry != nil { idpProvider, err := idpRegistry.GetProviderByTokenEndpoint(ctx, idpTokenURL, opts...) if err != nil { @@ -262,7 +260,7 @@ func (p *provisioner) appClientName() (string, error) { } template := oktaCfg.GetAppNameTemplate() - teamName, _ := util.GetAgentDetailsValue(p.app, agentDetailTeamName) + teamName, _ := util.GetAgentDetailsValue(p.app, provisioning.AgentDetailTeamName) name := strings.NewReplacer( config.OktaPlaceholderMPApplicationName, p.app.Name, config.OktaPlaceholderOwningTeam, teamName, diff --git a/pkg/apic/provisioning/idp/provisioner_test.go b/pkg/apic/provisioning/idp/provisioner_test.go index 0b0378f52..65ade16fc 100644 --- a/pkg/apic/provisioning/idp/provisioner_test.go +++ b/pkg/apic/provisioning/idp/provisioner_test.go @@ -100,7 +100,7 @@ func TestProvisioner(t *testing.T) { app.Spec.Security.EncryptionKey = tc.appKey cred := management.NewCredential("", "") cred.Spec.Data = map[string]any{ - IDPTokenURL: tc.credTokenURL, + provisioning.IDPTokenURL: tc.credTokenURL, provisioning.OauthTokenAuthMethod: tc.tokenAuthMethod, provisioning.OauthJwks: tc.publicKey, provisioning.OauthCertificate: tc.certificate, @@ -164,20 +164,20 @@ type mockProvider struct { capturedName string } -func (m *mockProvider) GetName() string { return "" } -func (m *mockProvider) GetTitle() string { return "" } -func (m *mockProvider) GetIssuer() string { return "" } -func (m *mockProvider) GetTokenEndpoint() string { return "" } -func (m *mockProvider) GetMTLSTokenEndpoint() string { return "" } -func (m *mockProvider) GetAuthorizationEndpoint() string { return "" } -func (m *mockProvider) GetSupportedScopes() []string { return nil } -func (m *mockProvider) GetSupportedGrantTypes() []string { return nil } -func (m *mockProvider) GetSupportedTokenAuthMethods() []string { return nil } -func (m *mockProvider) GetSupportedResponseMethod() []string { return nil } -func (m *mockProvider) Validate() error { return nil } -func (m *mockProvider) GetConfig() config.IDPConfig { return m.cfg } +func (m *mockProvider) GetName() string { return "" } +func (m *mockProvider) GetTitle() string { return "" } +func (m *mockProvider) GetIssuer() string { return "" } +func (m *mockProvider) GetTokenEndpoint() string { return "" } +func (m *mockProvider) GetMTLSTokenEndpoint() string { return "" } +func (m *mockProvider) GetAuthorizationEndpoint() string { return "" } +func (m *mockProvider) GetSupportedScopes() []string { return nil } +func (m *mockProvider) GetSupportedGrantTypes() []string { return nil } +func (m *mockProvider) GetSupportedTokenAuthMethods() []string { return nil } +func (m *mockProvider) GetSupportedResponseMethod() []string { return nil } +func (m *mockProvider) Validate() error { return nil } +func (m *mockProvider) GetConfig() config.IDPConfig { return m.cfg } func (m *mockProvider) GetMetadata() *oauth.AuthorizationServerMetadata { return nil } -func (m *mockProvider) GetIDPResourceName() string { return "" } +func (m *mockProvider) GetIDPResourceName() string { return "" } func (m *mockProvider) RegisterClient(meta oauth.ClientMetadata) (oauth.ClientMetadata, error) { m.capturedName = meta.GetClientName() @@ -278,7 +278,7 @@ func TestAppNameTemplate(t *testing.T) { mock := &mockProvider{cfg: tc.cfg} app := management.NewManagedApplication(tc.appName, "") if tc.teamName != "" { - assert.NoError(t, util.SetAgentDetailsKey(app, agentDetailTeamName, tc.teamName)) + assert.NoError(t, util.SetAgentDetailsKey(app, provisioning.AgentDetailTeamName, tc.teamName)) } cred := management.NewCredential(tc.credName, "") p := &provisioner{ diff --git a/pkg/authz/oauth/mockidpserver.go b/pkg/authz/oauth/mockidpserver.go index 9db7b7a2b..9f2461218 100644 --- a/pkg/authz/oauth/mockidpserver.go +++ b/pkg/authz/oauth/mockidpserver.go @@ -58,7 +58,6 @@ func NewMockIDPServer() MockIDPServer { tokenResponseCode: http.StatusOK, registerResponseCode: http.StatusCreated, } - m.server = httptest.NewServer(http.HandlerFunc(m.handleRequest)) m.serverMetadata = &AuthorizationServerMetadata{ Issuer: m.GetIssuer(), @@ -115,9 +114,6 @@ func (m *mockIDPServer) handleRequest(resp http.ResponseWriter, req *http.Reques if strings.Contains(req.RequestURI, "/register") { m.reqHeaders = req.Header m.reqQueryParam = req.URL.Query() - defer func() { - m.registerResponseCode = http.StatusCreated - }() if req.Method == http.MethodPost { if m.registerResponseCode != http.StatusCreated { resp.WriteHeader(m.registerResponseCode) diff --git a/pkg/authz/oauth/provider.go b/pkg/authz/oauth/provider.go index 010a6e0ef..14b715eba 100644 --- a/pkg/authz/oauth/provider.go +++ b/pkg/authz/oauth/provider.go @@ -739,7 +739,7 @@ func (p *provider) tryUnregister(unregisterURL, clientID, authPrefix, accessToke return err } - if response.Code == http.StatusNoContent { + if response.Code >= 200 && response.Code < 400 { return nil } diff --git a/pkg/authz/oauth/provider_test.go b/pkg/authz/oauth/provider_test.go index 45bf42acf..729a7a9f2 100644 --- a/pkg/authz/oauth/provider_test.go +++ b/pkg/authz/oauth/provider_test.go @@ -49,7 +49,6 @@ type providerTestCase struct { } func TestProvider(t *testing.T) { - cases := map[string]providerTestCase{ "IDP metadata bad request": { idpType: "generic", @@ -66,7 +65,7 @@ func TestProvider(t *testing.T) { expectRegistrationErr: true, }, "unregistration bad request": { - idpType: "okta", + idpType: "generic", clientRequest: &clientMetadata{ ClientName: "test", RedirectURIs: []string{testLocalHost}, @@ -82,8 +81,7 @@ func TestProvider(t *testing.T) { ResponseTypes: []string{AuthResponseCode}, Scope: []string{"read", "write"}, extraProperties: map[string]any{ - "key": "value", - oktaApplicationType: oktaAppTypeWeb, + "key": "value", }, }, metadataResponseCode: http.StatusOK, @@ -471,7 +469,7 @@ func TestUnregisterClientDeleteHookFails(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to complete provider cleanup after client unregistration") - assert.Equal(t, int32(1), deleteCalls.Load()) + assert.Equal(t, int32(0), deleteCalls.Load()) } func TestUnregisterClientCleanupAndDeleteFail(t *testing.T) { @@ -513,9 +511,9 @@ func TestUnregisterClientCleanupAndDeleteFail(t *testing.T) { err = p.UnregisterClient("cid-1", testToken, srv.URL+testRegisterURL, nil, "") assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to fully remove the Okta client") - assert.Contains(t, err.Error(), "OAuth client deletion failed") - assert.Equal(t, int32(1), deleteCalls.Load()) + assert.Contains(t, err.Error(), "cleanup failed") + assert.Contains(t, err.Error(), "failed to complete provider cleanup after client unregistration. Manual cleanup in Okta may be required") + assert.Equal(t, int32(0), deleteCalls.Load()) } func TestRegisterClientOktaScopesRequest(t *testing.T) { @@ -660,39 +658,39 @@ func TestNewProviderValidatesOktaGroup(t *testing.T) { func TestFilterScopeExclude(t *testing.T) { cases := map[string]struct { - scopes []string + scopes []string exclude string - want []string + want []string }{ "removes excluded scopes": { - scopes: []string{scopeOpenID, scopeProfile, testScope, scopeWriteAPI}, + scopes: []string{scopeOpenID, scopeProfile, testScope, scopeWriteAPI}, exclude: excludeOpenIDProfile, - want: []string{testScope, scopeWriteAPI}, + want: []string{testScope, scopeWriteAPI}, }, "empty exclude returns all scopes": { - scopes: []string{scopeOpenID, testScope}, + scopes: []string{scopeOpenID, testScope}, exclude: "", - want: []string{scopeOpenID, testScope}, + want: []string{scopeOpenID, testScope}, }, "exclude with whitespace is trimmed": { - scopes: []string{scopeOpenID, testScope}, + scopes: []string{scopeOpenID, testScope}, exclude: " openid , profile ", - want: []string{testScope}, + want: []string{testScope}, }, "no scopes match exclude returns all": { - scopes: []string{testScope, scopeWriteAPI}, + scopes: []string{testScope, scopeWriteAPI}, exclude: excludeOpenIDProfile, - want: []string{testScope, scopeWriteAPI}, + want: []string{testScope, scopeWriteAPI}, }, "all scopes excludeed returns empty": { - scopes: []string{scopeOpenID, scopeProfile}, + scopes: []string{scopeOpenID, scopeProfile}, exclude: excludeOpenIDProfile, - want: []string{}, + want: []string{}, }, "nil scopes returns nil": { - scopes: nil, + scopes: nil, exclude: scopeOpenID, - want: nil, + want: nil, }, } for name, tc := range cases { From a7507e92b6c341ba69f48dffbc50c67e0dd08b72 Mon Sep 17 00:00:00 2001 From: Dragos Gabriel Ghinea Date: Fri, 26 Jun 2026 14:02:46 +0300 Subject: [PATCH 16/22] fix deprov --- pkg/agent/handler/credential.go | 120 +++++++++++++----------- pkg/agent/handler/idpclientlifecycle.go | 32 +++++++ 2 files changed, 96 insertions(+), 56 deletions(-) diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index 823d1603d..53951804c 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -218,43 +218,52 @@ func (h *credentials) onDeleting(ctx context.Context, cred *management.Credentia status := h.prov.CredentialDeprovision(provCreds) - h.deprovisionPostProcess(status, provCreds, logger, ctx, cred) + h.deprovisionPostProcess(status, provCreds, logger, ctx, cred, app) } -func (h *credentials) deprovisionPostProcess(status prov.RequestStatus, provCreds *provCreds, logger log.FieldLogger, ctx context.Context, cred *management.Credential) { - if status.GetStatus() == prov.Success { - if provCreds.IsIDPCredential() && !isExternalCredential(cred) { - err := provCreds.idpProvisioner.UnregisterClient() - if err != nil { - logger. - WithError(err). - WithField("client_id", provCreds.idpProvisioner.GetIDPCredentialData().GetClientID()). - WithField("provider", provCreds.GetIDPProvider().GetName()). - Warn("error deprovisioning credential request from IDP, please ask administrator to remove the client from IdP") - } - } - - ri, _ := cred.AsInstance() - h.client.UpdateResourceFinalizer(ri, crFinalizer, "", false) - - // update sub resources when expire - if cred.Metadata.State != v1.ResourceDeleting { - cred.State.Name = v1.Inactive - cred.Status.Level = prov.Success.String() - cred.Status.Reasons = []v1.ResourceStatusReason{} - h.client.CreateSubResource(cred.ResourceMeta, map[string]interface{}{ - "state": cred.State, - }) - h.client.CreateSubResource(cred.ResourceMeta, map[string]interface{}{ - "status": cred.Status, - }) - } - } else { +func (h *credentials) deprovisionPostProcess(status prov.RequestStatus, provCreds *provCreds, logger log.FieldLogger, + ctx context.Context, cred *management.Credential, app *management.ManagedApplication) { + if status.GetStatus() != prov.Success { err := errors.New(status.GetMessage()) logger.WithError(err).Error("request status was not Success, skipping") h.onError(ctx, cred, err) h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + return + } + + if provCreds.IsIDPCredential() && !isExternalCredential(cred) { + clientID := provCreds.idpProvisioner.GetIDPCredentialData().GetClientID() + tokenURL := provCreds.GetIDPProvider().GetTokenEndpoint() + providerName := provCreds.GetIDPProvider().GetName() + logger = logger.WithField("client_id", clientID).WithField("provider", providerName).WithField("tokenURL", tokenURL) + + unregisterErr := provCreds.idpProvisioner.UnregisterClient() + if unregisterErr != nil { + logger.WithError(unregisterErr).Warn("error deprovisioning credential request from IDP, please ask administrator to remove the client from IdP") + } else { + if err := removeClientIDFromManagedApp(logger, h.client, app, clientID, tokenURL); err != nil { + logger.WithError(err).Warn("error removing clientID from Managed App clientIDs array") + } + } + } + + ri, _ := cred.AsInstance() + h.client.UpdateResourceFinalizer(ri, crFinalizer, "", false) + + // update sub resources when expire + if cred.Metadata.State == v1.ResourceDeleting { + return } + + cred.State.Name = v1.Inactive + cred.Status.Level = prov.Success.String() + cred.Status.Reasons = []v1.ResourceStatusReason{} + h.client.CreateSubResource(cred.ResourceMeta, map[string]interface{}{ + "state": cred.State, + }) + h.client.CreateSubResource(cred.ResourceMeta, map[string]interface{}{ + "status": cred.Status, + }) } func (h *credentials) onPending(ctx context.Context, cred *management.Credential) *management.Credential { @@ -272,20 +281,10 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential return cred } - if provCreds.IsIDPCredential() && !isExternalCredential(cred) { - if err := provCreds.idpProvisioner.RegisterClient(); err != nil { - logger.WithError(err).Error("error provisioning credential request with IDP") - h.onError(ctx, cred, err) - return cred - } - if err := persistIDPClientOnManagedApplication(fieldLogger, h.client, app, - provCreds.GetIDPCredentialData().GetClientID(), - provCreds.GetIDPProvider().GetTokenEndpoint(), - ); err != nil { - logger.WithError(err).Error("error storing IDP client reference on managed application") - h.onError(ctx, cred, err) - return cred - } + if err := h.registerIDPClient(cred, provCreds, app, logger); err != nil { + logger.WithError(err).Error("error registering IDP client") + h.onError(ctx, cred, err) + return cred } status, credentialData := h.provision(provCreds) @@ -461,18 +460,9 @@ func (h *credentials) onUpdates(ctx context.Context, cred *management.Credential return cred } - // if IDP and rotate the agent will register a new client - if action == prov.Rotate && provCreds.IsIDPCredential() && !isExternalCredential(cred) { - if err := provCreds.idpProvisioner.RegisterClient(); err != nil { - logger.WithError(err).Error("error provisioning credential request with IDP") - h.onError(ctx, cred, err) - return cred - } - if err := persistIDPClientOnManagedApplication(fieldLogger, h.client, app, - provCreds.GetIDPCredentialData().GetClientID(), - provCreds.GetIDPProvider().GetTokenEndpoint(), - ); err != nil { - logger.WithError(err).Error("error storing IDP client reference on managed application") + if action == prov.Rotate { + if err := h.registerIDPClient(cred, provCreds, app, logger); err != nil { + logger.WithError(err).Error("error registering IDP client") h.onError(ctx, cred, err) return cred } @@ -614,6 +604,24 @@ func (h *credentials) newProvCreds(cr *management.Credential, app *management.Ma return provCred, nil } +// registerIDPClient registers a new IDP client and (for Okta) persists the reference +// on the managed app. It is a no-op when not an IDP credential or is external. +func (h *credentials) registerIDPClient(cred *management.Credential, provCreds *provCreds, app *management.ManagedApplication, logger log.FieldLogger) error { + if !provCreds.IsIDPCredential() || isExternalCredential(cred) { + return nil + } + if err := provCreds.idpProvisioner.RegisterClient(); err != nil { + return fmt.Errorf("error provisioning credential request with IDP: %w", err) + } + if provCreds.GetIDPProvider().GetConfig().GetIDPType() != oauth.TypeOkta { + return nil + } + return persistIDPClientOnManagedApplication(logger, h.client, app, + provCreds.GetIDPCredentialData().GetClientID(), + provCreds.GetIDPProvider().GetTokenEndpoint(), + ) +} + // GetApplicationName gets the name of the managed application func (c provCreds) GetApplicationName() string { return c.managedApp diff --git a/pkg/agent/handler/idpclientlifecycle.go b/pkg/agent/handler/idpclientlifecycle.go index 98e6d569e..cdbf5f6a6 100644 --- a/pkg/agent/handler/idpclientlifecycle.go +++ b/pkg/agent/handler/idpclientlifecycle.go @@ -53,6 +53,38 @@ func persistIDPClientOnManagedApplication(logger log.FieldLogger, client client, return nil } +func removeClientIDFromManagedApp(logger log.FieldLogger, client client, app *management.ManagedApplication, clientID, tokenURL string) error { + if app == nil || clientID == "" { + return nil + } + + details := util.GetAgentDetails(app) + if details == nil { + return nil + } + + clientIDs := extractClientIDs(details[oktaClientIDsAgentDetail]) + if len(clientIDs) == 0 { + logger.Trace("empty clientIDs agent detail") + return nil + } + + for i := len(clientIDs) - 1; i >= 0; i-- { + if clientIDs[i] != clientID { + continue + } + clientIDs = append(clientIDs[:i], clientIDs[i+1:]...) + details[oktaClientIDsAgentDetail] = clientIDs + if err := client.CreateSubResource(app.ResourceMeta, map[string]interface{}{defs.XAgentDetails: details}); err != nil { + return fmt.Errorf("could not persist IDP client reference on managed application %s: %w", app.Name, err) + } + return nil + } + + logger.WithField("clientID", clientID).Trace("could not find the clientID to remove from managedApp x-agent-details") + return nil +} + // cleanupManagedApplicationIDPClients removes any tracked IDP clients for a ManagedApplication. func cleanupManagedApplicationIDPClients(ctx context.Context, logger log.FieldLogger, registry oauth.IdPRegistry, app *management.ManagedApplication) error { if registry == nil || app == nil { From 47189bcb94f3c80807407092947ee50ea5d00bca Mon Sep 17 00:00:00 2001 From: Dale Feldick Date: Wed, 17 Jun 2026 14:49:24 -0700 Subject: [PATCH 17/22] APIGOV-32989 - update golang.org/x libraries (#1050) --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 4b2131635..ca36034c4 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,8 @@ require ( github.com/subosito/gotenv v1.4.0 github.com/swaggest/go-asyncapi v0.8.0 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 - golang.org/x/net v0.55.0 - golang.org/x/text v0.37.0 + golang.org/x/net v0.56.0 + golang.org/x/text v0.38.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 @@ -90,13 +90,13 @@ require ( go.elastic.co/fastjson v1.5.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/lint v0.0.0-20241112194109-818c5a804067 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/ini.v1 v1.66.6 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 9c9ca0241..c55774d14 100644 --- a/go.sum +++ b/go.sum @@ -429,8 +429,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -466,8 +466,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -504,8 +504,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -525,8 +525,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -573,10 +573,10 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -586,8 +586,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -641,8 +641,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From d6e0c6c3876b77109e066f95c9f688cd32f0f197 Mon Sep 17 00:00:00 2001 From: Vivek Singh Chauhan Date: Thu, 18 Jun 2026 16:51:32 +0100 Subject: [PATCH 18/22] APIGOV-32994 - Fix to create Idp resource if not already existing (#1051) --- pkg/authz/oauth/idplifecycle.go | 6 +----- pkg/authz/oauth/idplifecycle_test.go | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/authz/oauth/idplifecycle.go b/pkg/authz/oauth/idplifecycle.go index 4278e779e..b526ced2e 100644 --- a/pkg/authz/oauth/idplifecycle.go +++ b/pkg/authz/oauth/idplifecycle.go @@ -89,11 +89,7 @@ func (l *idpEngageLifecycle) CreateEngageResourcesFromMetadata(idpLogger log.Fie } // Check if an IdentityProvider with the same name already exists - ri, err := l.client.GetResource(idpResource.GetSelfLink()) - if err != nil { - return "", err - } - + ri, _ := l.client.GetResource(idpResource.GetSelfLink()) if ri == nil { ri, err = l.client.CreateOrUpdateResource(idpResource) if err != nil { diff --git a/pkg/authz/oauth/idplifecycle_test.go b/pkg/authz/oauth/idplifecycle_test.go index 60e19bb30..3c14271d0 100644 --- a/pkg/authz/oauth/idplifecycle_test.go +++ b/pkg/authz/oauth/idplifecycle_test.go @@ -353,8 +353,8 @@ func TestCreateEngageResourcesGetResourceError(t *testing.T) { metadata, idpCfg := makeTestMetadata(t) _, err := NewIDPEngageLifecycle(client, newMockIdpCache()).CreateEngageResourcesFromMetadata(newTestLogger(), idpCfg, idpCfg.GetIDPType(), idpCfg.GetIDPName(), metadata, "/env", management.EnvironmentPoliciesCredentials{}) - assert.Error(t, err) - assert.Equal(t, 0, created) + assert.Nil(t, err) + assert.Equal(t, 2, created) } func TestCreateEngageResourcesIDPFoundViaGetResource(t *testing.T) { From 23fa54d871425ea7c6fc0658d53c7a3f5e2cda78 Mon Sep 17 00:00:00 2001 From: Alin Rosca <137875910+alrosca@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:11:29 +0300 Subject: [PATCH 19/22] APIGOV-31191 validate cache (#1015) * APIGOV-31191 validate cache * APIGOV-31191 improvements and unit tests * APIGOV-31191 refactor tests * APIGOV-31191 validate instead of rebuild after 7 days * APIGOV-31191 get scoped resources from cache * APIGOV-31191 update shouldRebuildCache * APIGOV-31191 fix logs * APIGOV-31191 improvements * APIGOV-32372 cache rebuild optimizations (#1023) * APIGOV-32372 rebuild only failed kinds * APIGOV-32372 fix for listener/cache rebuild race condition * APIGOV-32372 merge main * APIGOV-32372 remove else statement Co-authored-by: Copilot * APIGOV-32327 wait for clients to be ready * APIGOV-32372 improvements * APIGOV-32372 fix for unlock on unlocked mutex error * APIGOV-32372 guard against listener potential data race --------- Co-authored-by: Copilot * APIGOV-31191 sequence and resource count pre validation * APIGOV-31191 - remove timeout on harverter event sync * APIGOV-31191 - Cache validation/rebuild refactoring (#1048) * APIGOV-31191 - remove API calls for metadata check for cache validation * APIGOV-31191 - Refactoring and cleanup - Remove entire cache flush. Flush only the kind getting processed and after the API call is successful to avoid cache inconsistency - Cleanup instance count map that should speed up building APIservice cache items - Fixes * APIGOV-31191 - check publishing lock and acquire lock while publishing * APIGOV-31191 - fixes - Fix for endless waitForCacheRebuild - Fix to keep original APIService resource instance in cache when duplicates are added to the cache - Fix to hook cache rebuild on stream client reconnection instead of harvester error and to address job pool status * APIGOV-31191 - updates - remove waitForCacheRebuild and use the error returned from harvester to trigger the job restart * APIGOV-31191 - fallback to count checks if harvester is not reachable * APIGOV-31191 - review comments + lock on harvester event sync * APIGOV-31191 - lock on harvester event sync * APIGOV-31191 - use APIServiceInstance counts to rebuild APIService cache * APIGOV-31191 - use CreateOrUpdateResource for processing APIService * APIGOV-31191 - fix to not make multiple x-agent-details update for APIService * APIGOV-31191 - clean up event listener pauser * APIGOV-31191 - fix client adapter --------- Co-authored-by: Copilot Co-authored-by: Vivek Singh Chauhan --- pkg/agent/agent.go | 10 +- pkg/agent/cache/apiservice.go | 120 ++--- pkg/agent/cache/apiservice_test.go | 117 +++++ pkg/agent/cache/apiserviceinstance.go | 38 -- pkg/agent/cache/cachevalidation.go | 138 +++++ pkg/agent/cache/cachevalidation_test.go | 295 +++++++++++ pkg/agent/cache/loaders.go | 18 - pkg/agent/cache/manager.go | 15 +- pkg/agent/cachevalidationjob.go | 200 +++++++ pkg/agent/cachevalidationjob_test.go | 497 ++++++++++++++++++ pkg/agent/discovery.go | 24 +- pkg/agent/discovery_test.go | 92 ++++ pkg/agent/discoverycache.go | 41 +- pkg/agent/discoverycache_test.go | 93 ++-- pkg/agent/events/eventlistener.go | 4 +- pkg/agent/events/eventlistener_test.go | 29 + pkg/agent/eventsjob.go | 2 + pkg/agent/eventsync.go | 133 +++-- pkg/agent/eventsync_test.go | 298 +++++++---- pkg/agent/handler/agentresource_test.go | 3 +- pkg/agent/handler/apiservice.go | 15 + pkg/agent/handler/discoveryaccessrequest.go | 53 ++ .../handler/discoveryaccessrequest_test.go | 237 +++++++++ .../handler/discoverymanagedapplication.go | 53 ++ .../discoverymanagedapplication_test.go | 134 +++++ pkg/agent/handler/marketplacehandler.go | 2 +- pkg/agent/handler/traceaccessrequest.go | 2 +- pkg/agent/handler/tracemanagedapplication.go | 2 +- pkg/agent/idplifecycle.go | 22 +- pkg/agent/idplifecycle_test.go | 19 +- pkg/agent/poller/client.go | 56 +- pkg/agent/poller/client_test.go | 82 +++ pkg/agent/poller/options.go | 8 + pkg/agent/provisioning_test.go | 7 +- pkg/agent/resource/manager.go | 77 +-- pkg/agent/resource/manager_test.go | 114 ++++ pkg/agent/stream/client.go | 55 +- pkg/agent/stream/client_test.go | 74 ++- pkg/agent/stream/options.go | 8 + pkg/apic/apiservice.go | 25 +- pkg/apic/apiservice_test.go | 64 +-- pkg/apic/apiserviceinstance.go | 2 - pkg/apic/client.go | 65 ++- pkg/apic/client_test.go | 8 +- pkg/apic/mock/mockclient.go | 15 +- .../accessrequestdefinitionbuilder.go | 6 +- .../applicationprofiledefinitionbuilder.go | 6 +- .../credentialrequestdefinitionbuilder.go | 4 - pkg/apic/resourcePagination.go | 29 + pkg/compliance/compliance_test.go | 3 +- pkg/compliance/job.go | 3 +- pkg/harvester/harvesterclient.go | 38 +- pkg/migrate/apisimigration_test.go | 3 +- pkg/migrate/ardmigration_test.go | 3 +- pkg/migrate/attributemigration.go | 3 +- pkg/migrate/attributemigration_test.go | 3 +- 56 files changed, 2935 insertions(+), 532 deletions(-) create mode 100644 pkg/agent/cache/apiservice_test.go create mode 100644 pkg/agent/cache/cachevalidation.go create mode 100644 pkg/agent/cache/cachevalidation_test.go create mode 100644 pkg/agent/cachevalidationjob.go create mode 100644 pkg/agent/cachevalidationjob_test.go create mode 100644 pkg/agent/discovery_test.go create mode 100644 pkg/agent/handler/discoveryaccessrequest.go create mode 100644 pkg/agent/handler/discoveryaccessrequest_test.go create mode 100644 pkg/agent/handler/discoverymanagedapplication.go create mode 100644 pkg/agent/handler/discoverymanagedapplication_test.go diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 0f3ad40f8..47e226e9f 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -9,6 +9,7 @@ import ( "runtime/pprof" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -77,9 +78,10 @@ type agentData struct { streamer *stream.StreamerClient authProviderRegistry oauth.ProviderRegistry - finalizeAgentInit func() error - publishingLock *sync.Mutex - ardLock sync.Mutex + finalizeAgentInit func() error + publishingLock *sync.Mutex + publishingLockAcquired atomic.Bool + ardLock sync.Mutex status string applicationProfileDefinition string @@ -818,6 +820,8 @@ func newHandlers() []handler.Handler { handler.NewARDHandler(agent.cacheManager), handler.NewAPDHandler(agent.cacheManager), handler.NewEnvironmentHandler(agent.cacheManager, agent.cfg.GetCredentialConfig(), envName), + handler.NewDiscoveryManagedApplicationHandler(agent.cacheManager), + handler.NewDiscoveryAccessRequestHandler(agent.cacheManager), handler.NewIDPHandler(agent.cacheManager, agent.cfg.GetCredentialConfig()), ) case config.TraceabilityAgent: diff --git a/pkg/agent/cache/apiservice.go b/pkg/agent/cache/apiservice.go index d6c08622c..071127d65 100644 --- a/pkg/agent/cache/apiservice.go +++ b/pkg/agent/cache/apiservice.go @@ -2,6 +2,7 @@ package cache import ( "fmt" + "time" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -10,12 +11,6 @@ import ( "github.com/Axway/agent-sdk/pkg/util" ) -// apiServiceToInstanceCount -type apiServiceToInstanceCount struct { - Count int - ApiServiceKey string -} - // API service cache management // AddAPIService - add/update APIService resource in cache @@ -28,29 +23,44 @@ func (c *cacheManager) AddAPIService(svc *v1.ResourceInstance) error { defer c.setCacheUpdated(true) apiName, _ := util.GetAgentDetailsValue(svc, defs.AttrExternalAPIName) primaryKey, _ := util.GetAgentDetailsValue(svc, defs.AttrExternalAPIPrimaryKey) - cachedRI, _ := c.GetAPIServiceInstanceByName(apiName) - if primaryKey != "" { - // Verify secondary key and validate if we need to remove it from the apiMap (cache) - if _, err := c.apiMap.Get(apiID); err != nil { - c.apiMap.Delete(apiID) + // Verify secondary key and validate if we need to remove it from the apiMap (cache) + shouldAdd := true + existing, _ := c.apiMap.Get(apiID) + if existing == nil { + existing, _ = c.apiMap.GetBySecondaryKey(apiID) + } + if existing != nil { + // if cached APIService is created after the incoming one, its likely a duplicate created due to a previous cache sync issue. + // Use the original APIService to cache the instance and remove the duplicate from cache to prevent flapping. + apiSvc, ok := existing.(*v1.ResourceInstance) + if ok && apiSvc != nil && apiSvc.Metadata.ID != svc.Metadata.ID { + existingAPITime := time.Time(apiSvc.Metadata.Audit.CreateTimestamp) + newAPITime := time.Time(svc.Metadata.Audit.CreateTimestamp) + if existingAPITime.After(newAPITime) { + if err := c.apiMap.Delete(apiID); err != nil { + c.apiMap.DeleteBySecondaryKey(apiID) + } + } else { + shouldAdd = false + } } - - c.apiMap.SetWithSecondaryKey(primaryKey, apiID, svc) - c.apiMap.SetSecondaryKey(primaryKey, apiName) - c.apiMap.SetSecondaryKey(primaryKey, svc.Name) - } else { - c.apiMap.SetWithSecondaryKey(apiID, apiName, svc) - c.apiMap.SetSecondaryKey(apiID, svc.Name) } - if cachedRI == nil { - c.countCachedInstancesForAPIService(apiID, primaryKey) - } + if shouldAdd { + if primaryKey != "" { + c.apiMap.SetWithSecondaryKey(primaryKey, apiID, svc) + c.apiMap.SetSecondaryKey(primaryKey, apiName) + c.apiMap.SetSecondaryKey(primaryKey, svc.Name) + } else { + c.apiMap.SetWithSecondaryKey(apiID, apiName, svc) + c.apiMap.SetSecondaryKey(apiID, svc.Name) + } - c.logger. - WithField("api-name", apiName). - WithField("api-id", apiID). - Trace("added api to cache") + c.logger. + WithField("api-name", apiName). + WithField("api-id", apiID). + Trace("added api to cache") + } } return nil @@ -161,66 +171,6 @@ func (c *cacheManager) FormatKey(svcName string) string { return formatKey } -func (c *cacheManager) addToServiceInstanceCount(apiID, primaryKey string) error { - svc := c.GetAPIServiceWithPrimaryKey(primaryKey) - if svc == nil { - svc = c.GetAPIServiceWithAPIID(apiID) - if svc == nil { - // can't increment a count for a service we can't find - c.logger. - WithField("primary-key", primaryKey). - WithField("api-id", apiID). - Debug("cannot increment a count for a service we cannot find") - return nil - } - } - key := c.FormatKey(svc.Name) - - svcCountI, _ := c.instanceCountMap.Get(key) - svcCount := apiServiceToInstanceCount{} - if svcCountI == nil { - svcCount = apiServiceToInstanceCount{ - Count: 0, - ApiServiceKey: svc.Metadata.ID, - } - } else { - svcCount = svcCountI.(apiServiceToInstanceCount) - } - svcCount.Count++ - - c.instanceCountMap.Set(key, svcCount) - return nil -} - -func (c *cacheManager) removeFromServiceInstanceCount(apiID, primaryKey string) error { - svc := c.GetAPIServiceWithPrimaryKey(primaryKey) - if svc == nil { - svc = c.GetAPIServiceWithAPIID(apiID) - if svc == nil { - // can't decrement a count for a service we can't find - return nil - } - } - key := c.FormatKey(svc.Name) - - svcCountI, err := c.instanceCountMap.Get(key) - if err != nil { - return err - } - svcCount := apiServiceToInstanceCount{} - if svcCountI != nil { - svcCount = svcCountI.(apiServiceToInstanceCount) - svcCount.Count-- - } - - c.instanceCountMap.Set(key, svcCount) - return nil -} - -func (c *cacheManager) deleteAllServiceInstanceCounts() { - c.instanceCountMap.Flush() -} - func (c *cacheManager) GetAPIServiceInstancesByService(svcName string) []*v1.ResourceInstance { svc := c.GetAPIServiceWithName(svcName) diff --git a/pkg/agent/cache/apiservice_test.go b/pkg/agent/cache/apiservice_test.go new file mode 100644 index 000000000..1ab49b3b6 --- /dev/null +++ b/pkg/agent/cache/apiservice_test.go @@ -0,0 +1,117 @@ +package cache + +import ( + "testing" + "time" + + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/stretchr/testify/assert" +) + +// createAPIServiceWithTime builds a ResourceInstance with a specific CreateTimestamp so that +// duplicate-detection logic (which compares timestamps) can be exercised deterministically. +func createAPIServiceWithTime(apiID, apiName, primaryKey string, ts time.Time) *v1.ResourceInstance { + svc := createAPIService(apiID, apiName, primaryKey) + svc.Metadata.Audit.CreateTimestamp = v1.Time(ts) + return svc +} + +// TestAddAPIServiceDuplicateDetection validates that AddAPIService keeps the older (original) +// ResourceInstance in the cache and discards the newer one when two entries share the same +// external API ID, covering both the no-primaryKey and the primaryKey storage paths. +func TestAddAPIServiceDuplicateDetection(t *testing.T) { + older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + primaryKey string + firstTS time.Time // timestamp on the first-added entry + secondTS time.Time // timestamp on the second-added entry + wantInCache string // Name of the ResourceInstance that should survive + wantNotInCache string // Name of the ResourceInstance that should be absent + }{ + { + name: "no primaryKey - second is newer duplicate, original stays", + primaryKey: "", + firstTS: older, + secondTS: newer, + wantInCache: "name-api1-original", + wantNotInCache: "name-api1-duplicate", + }, + { + name: "no primaryKey - second is older original, duplicate evicted", + primaryKey: "", + firstTS: newer, + secondTS: older, + wantInCache: "name-api1-original", + wantNotInCache: "name-api1-duplicate", + }, + { + name: "with primaryKey - second is newer duplicate, original stays", + primaryKey: "pk1", + firstTS: older, + secondTS: newer, + wantInCache: "name-api1-original", + wantNotInCache: "name-api1-duplicate", + }, + { + name: "with primaryKey - second is older original, duplicate evicted", + primaryKey: "pk1", + firstTS: newer, + secondTS: older, + wantInCache: "name-api1-original", + wantNotInCache: "name-api1-duplicate", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := NewAgentCacheManager(&config.CentralConfiguration{}, false) + + // The "first" entry is the one added earlier in the sequence. + // Its Name encodes which role it plays so we can assert the right one survived. + firstIsOriginal := tc.firstTS.Before(tc.secondTS) + var firstEntry, secondEntry *v1.ResourceInstance + if firstIsOriginal { + firstEntry = createAPIServiceWithTime("id1", "api1", tc.primaryKey, tc.firstTS) + firstEntry.Name = "name-api1-original" + firstEntry.Metadata.ID = "id1" + secondEntry = createAPIServiceWithTime("id1", "api1", tc.primaryKey, tc.secondTS) + secondEntry.Name = "name-api1-duplicate" + secondEntry.Metadata.ID = "id2" + } else { + firstEntry = createAPIServiceWithTime("id1", "api1", tc.primaryKey, tc.firstTS) + firstEntry.Name = "name-api1-duplicate" + firstEntry.Metadata.ID = "id1" + secondEntry = createAPIServiceWithTime("id1", "api1", tc.primaryKey, tc.secondTS) + secondEntry.Name = "name-api1-original" + secondEntry.Metadata.ID = "id2" + } + + err := m.AddAPIService(firstEntry) + assert.NoError(t, err) + err = m.AddAPIService(secondEntry) + assert.NoError(t, err) + + // Exactly one entry should be in the cache. + keys := m.GetAPIServiceKeys() + assert.Len(t, keys, 1, "expected exactly one entry in cache after duplicate handling") + + // The surviving entry must be the original (older) one. + survived := m.GetAPIServiceWithAPIID("id1") + assert.NotNil(t, survived, "original APIService should be retrievable by apiID") + assert.Equal(t, tc.wantInCache, survived.Name, "wrong entry survived") + + // The duplicate should be gone. + allKeys := m.GetAPIServiceKeys() + for _, k := range allKeys { + item, _ := m.GetAPIServiceCache().Get(k) + if ri, ok := item.(*v1.ResourceInstance); ok { + assert.NotEqual(t, tc.wantNotInCache, ri.Name, "duplicate should not be in cache") + } + } + }) + } +} diff --git a/pkg/agent/cache/apiserviceinstance.go b/pkg/agent/cache/apiserviceinstance.go index b767310e6..c11aae1a8 100644 --- a/pkg/agent/cache/apiserviceinstance.go +++ b/pkg/agent/cache/apiserviceinstance.go @@ -2,8 +2,6 @@ package cache import ( v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" - defs "github.com/Axway/agent-sdk/pkg/apic/definitions" - "github.com/Axway/agent-sdk/pkg/util" ) // API service instance management @@ -16,14 +14,7 @@ func (c *cacheManager) AddAPIServiceInstance(resource *v1.ResourceInstance) { WithField("apiID", resource.Metadata.ID). Trace("AddAPIServiceInstance") - cachedRI, _ := c.GetAPIServiceInstanceByID(resource.Metadata.ID) c.instanceMap.SetWithSecondaryKey(resource.Metadata.ID, resource.Name, resource) - - if cachedRI == nil { - apiID, _ := util.GetAgentDetailsValue(resource, defs.AttrExternalAPIID) - primaryKey, _ := util.GetAgentDetailsValue(resource, defs.AttrExternalAPIPrimaryKey) - c.addToServiceInstanceCount(apiID, primaryKey) - } } // GetAPIServiceInstanceKeys - returns keys for APIServiceInstance cache @@ -70,13 +61,6 @@ func (c *cacheManager) GetAPIServiceInstanceByName(name string) (*v1.ResourceIns func (c *cacheManager) DeleteAPIServiceInstance(id string) error { defer c.setCacheUpdated(true) - ri, _ := c.GetAPIServiceInstanceByID(id) - if ri != nil { - apiID, _ := util.GetAgentDetailsValue(ri, defs.AttrExternalAPIID) - primaryKey, _ := util.GetAgentDetailsValue(ri, defs.AttrExternalAPIPrimaryKey) - c.removeFromServiceInstanceCount(apiID, primaryKey) - } - return c.instanceMap.Delete(id) } @@ -84,7 +68,6 @@ func (c *cacheManager) DeleteAPIServiceInstance(id string) error { func (c *cacheManager) DeleteAllAPIServiceInstance() { defer c.setCacheUpdated(true) - c.deleteAllServiceInstanceCounts() c.instanceMap.Flush() } @@ -108,24 +91,3 @@ func (c *cacheManager) ListAPIServiceInstances() []*v1.ResourceInstance { return instances } - -// countCachedInstancesForAPIService - count any instances in the cache for the newly added api -func (c *cacheManager) countCachedInstancesForAPIService(apiID, primaryKey string) { - c.logger. - WithField("primary-key", primaryKey). - WithField("api-id", apiID). - Trace("countCachedInstancesForAPIService") - - for _, k := range c.instanceMap.GetKeys() { - item, _ := c.instanceMap.Get(k) - inst, ok := item.(*v1.ResourceInstance) - if !ok { - continue - } - instAPIID, _ := util.GetAgentDetailsValue(inst, defs.AttrExternalAPIID) - instPrimary, _ := util.GetAgentDetailsValue(inst, defs.AttrExternalAPIPrimaryKey) - if apiID == instAPIID || primaryKey == instPrimary { - c.addToServiceInstanceCount(apiID, primaryKey) - } - } -} diff --git a/pkg/agent/cache/cachevalidation.go b/pkg/agent/cache/cachevalidation.go new file mode 100644 index 000000000..340070245 --- /dev/null +++ b/pkg/agent/cache/cachevalidation.go @@ -0,0 +1,138 @@ +package cache + +import ( + "time" + + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/cache" +) + +// IsCachedKind reports whether the given kind has a dedicated cache entry. +// Only kinds with dedicated caches are meaningful to validate against the API server. +func IsCachedKind(kind string) bool { + switch kind { + case management.APIServiceGVK().Kind, + management.APIServiceInstanceGVK().Kind, + management.ManagedApplicationGVK().Kind, + management.AccessRequestGVK().Kind, + management.AccessRequestDefinitionGVK().Kind, + management.CredentialRequestDefinitionGVK().Kind, + management.ApplicationProfileDefinitionGVK().Kind, + management.ComplianceRuntimeResultGVK().Kind: + return true + } + return false +} + +// GetCachedResourcesByKind returns a map of resource name to modification timestamp +// for all cached resources of the given group and kind. When scopeName is non-empty, +// only resources whose scope matches scopeName are included. +func (c *cacheManager) GetCachedResourcesByKind(group, kind, scopeName string) map[string]time.Time { + c.ApplyResourceReadLock() + defer c.ReleaseResourceReadLock() + + resourceCache := c.getCacheForKind(kind) + if resourceCache == nil { + // Fall back to the watch resource map for kinds not in a dedicated cache + return c.getWatchResourcesByKind(group, kind, scopeName) + } + + return c.extractResourceSummary(resourceCache, scopeName) +} + +// getCacheForKind returns the dedicated cache for the given resource kind, or nil +// if the kind is stored in the watch resource map. +func (c *cacheManager) getCacheForKind(kind string) cache.Cache { + switch kind { + case management.APIServiceGVK().Kind: + return c.apiMap + case management.APIServiceInstanceGVK().Kind: + return c.instanceMap + case management.ManagedApplicationGVK().Kind: + return c.managedApplicationMap + case management.AccessRequestGVK().Kind: + return c.accessRequestMap + case management.AccessRequestDefinitionGVK().Kind: + return c.ardMap + case management.CredentialRequestDefinitionGVK().Kind: + return c.crdMap + case management.ApplicationProfileDefinitionGVK().Kind: + return c.apdMap + case management.ComplianceRuntimeResultGVK().Kind: + return c.crrMap + default: + return nil + } +} + +// FlushKind empties the dedicated cache for the given resource kind. +// Kinds without a dedicated cache are silently ignored. +func (c *cacheManager) FlushKind(kind string) { + c.ApplyResourceReadLock() + defer c.ReleaseResourceReadLock() + + c.logger.WithField("kind", kind).Debug("flushing cache for resource kind") + + if resourceCache := c.getCacheForKind(kind); resourceCache != nil { + resourceCache.Flush() + } +} + +// ResourceCacheKey builds a unique cache key from kind, scope name, and resource name. +func ResourceCacheKey(kind, scopeName, name string) string { + return kind + "/" + scopeName + "/" + name +} + +// extractResourceSummary iterates the cache keys and returns a composite key -> modifyTimestamp. +// When scopeName is non-empty, only resources whose scope matches scopeName are included. +func (c *cacheManager) extractResourceSummary(resourceCache cache.Cache, scopeName string) map[string]time.Time { + result := make(map[string]time.Time) + keys := resourceCache.GetKeys() + + for _, key := range keys { + item, err := resourceCache.Get(key) + if err != nil { + continue + } + ri, ok := item.(*v1.ResourceInstance) + if !ok || ri == nil { + continue + } + if scopeName != "" && ri.Metadata.Scope.Name != scopeName { + continue + } + modTime := time.Time(ri.Metadata.Audit.ModifyTimestamp) + result[ResourceCacheKey(ri.Kind, ri.Metadata.Scope.Name, ri.Name)] = modTime + } + + return result +} + +// getWatchResourcesByKind iterates the watch resource map and returns resources +// matching the given group and kind. When scopeName is non-empty, only resources +// whose scope matches scopeName are included. +func (c *cacheManager) getWatchResourcesByKind(group, kind, scopeName string) map[string]time.Time { + result := make(map[string]time.Time) + + keys := c.watchResourceMap.GetKeys() + for _, key := range keys { + item, err := c.watchResourceMap.Get(key) + if err != nil { + continue + } + ri, ok := item.(*v1.ResourceInstance) + if !ok || ri == nil { + continue + } + if scopeName != "" && ri.Metadata.Scope.Name != scopeName { + continue + } + if ri.Group == group && ri.Kind == kind { + modTime := time.Time(ri.Metadata.Audit.ModifyTimestamp) + result[ResourceCacheKey(ri.Kind, ri.Metadata.Scope.Name, ri.Name)] = modTime + } + } + + return result +} diff --git a/pkg/agent/cache/cachevalidation_test.go b/pkg/agent/cache/cachevalidation_test.go new file mode 100644 index 000000000..cc120a240 --- /dev/null +++ b/pkg/agent/cache/cachevalidation_test.go @@ -0,0 +1,295 @@ +package cache + +import ( + "testing" + "time" + + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/stretchr/testify/assert" +) + +func makeRI(group, kind, scopeKind, scopeName, name, id string, modTime time.Time) *v1.ResourceInstance { + return &v1.ResourceInstance{ + ResourceMeta: v1.ResourceMeta{ + GroupVersionKind: v1.GroupVersionKind{ + GroupKind: v1.GroupKind{ + Group: group, + Kind: kind, + }, + APIVersion: "v1alpha1", + }, + Metadata: v1.Metadata{ + ID: id, + Scope: v1.MetadataScope{ + Kind: scopeKind, + Name: scopeName, + }, + Audit: v1.AuditMetadata{ + ModifyTimestamp: v1.Time(modTime), + }, + }, + Name: name, + }, + } +} + +func makeAPIServiceRI(scopeName, name, apiID string, modTime time.Time) *v1.ResourceInstance { + ri := makeRI("management", management.APIServiceGVK().Kind, "Environment", scopeName, name, apiID, modTime) + ri.SubResources = map[string]interface{}{ + defs.XAgentDetails: map[string]interface{}{ + defs.AttrExternalAPIID: apiID, + defs.AttrExternalAPIName: name, + }, + } + return ri +} + +func TestResourceCacheKey(t *testing.T) { + tests := map[string]struct { + kind string + scope string + name string + expected string + }{ + "with scope": {kind: "APIService", scope: "env1", name: "svc1", expected: "APIService/env1/svc1"}, + "empty scope": {kind: "APIService", scope: "", name: "svc1", expected: "APIService//svc1"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, ResourceCacheKey(tc.kind, tc.scope, tc.name)) + }) + } +} + +func TestGetCachedResourcesByKind(t *testing.T) { + modTime := time.Date(2026, 3, 12, 10, 0, 0, 0, time.UTC) + modTime2 := time.Date(2026, 3, 12, 11, 0, 0, 0, time.UTC) + + type testCase struct { + setup func(Manager) + group string + kind string + scopeName string + expectLen int + expectKeys []string + verify func(t *testing.T, result map[string]time.Time) + } + + tests := map[string]testCase{ + "APIService": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + }, + group: "management", + kind: management.APIServiceGVK().Kind, + expectLen: 1, + expectKeys: []string{ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc1")}, + verify: func(t *testing.T, result map[string]time.Time) { + assert.Equal(t, modTime, result[ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc1")]) + }, + }, + "APIServiceInstance": { + setup: func(cm Manager) { + cm.AddAPIServiceInstance(makeRI("management", management.APIServiceInstanceGVK().Kind, "Environment", "env1", "inst1", "id1", modTime)) + }, + group: "management", + kind: management.APIServiceInstanceGVK().Kind, + expectLen: 1, + expectKeys: []string{ResourceCacheKey(management.APIServiceInstanceGVK().Kind, "env1", "inst1")}, + }, + "ManagedApplication": { + setup: func(cm Manager) { + cm.AddManagedApplication(makeRI("management", management.ManagedApplicationGVK().Kind, "Environment", "env1", "app1", "id1", modTime)) + }, + group: "management", + kind: management.ManagedApplicationGVK().Kind, + expectLen: 1, + expectKeys: []string{ResourceCacheKey(management.ManagedApplicationGVK().Kind, "env1", "app1")}, + }, + "AccessRequest": { + setup: func(cm Manager) { + cm.AddAccessRequest(makeRI("management", management.AccessRequestGVK().Kind, "Environment", "env1", "ar1", "id1", modTime)) + }, + group: "management", + kind: management.AccessRequestGVK().Kind, + expectLen: 1, + expectKeys: []string{ResourceCacheKey(management.AccessRequestGVK().Kind, "env1", "ar1")}, + }, + "multiple resources same kind": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + cm.AddAPIService(makeAPIServiceRI("env1", "svc2", "ext-id-2", modTime2)) + }, + group: "management", + kind: management.APIServiceGVK().Kind, + expectLen: 2, + expectKeys: []string{ + ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc1"), + ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc2"), + }, + }, + "different scopes - no scope filter returns all": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + cm.AddAPIService(makeAPIServiceRI("env2", "svc1", "ext-id-2", modTime)) + }, + group: "management", + kind: management.APIServiceGVK().Kind, + expectLen: 2, + expectKeys: []string{ + ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc1"), + ResourceCacheKey(management.APIServiceGVK().Kind, "env2", "svc1"), + }, + }, + "different scopes - scoped to env1 returns only env1": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + cm.AddAPIService(makeAPIServiceRI("env2", "svc1", "ext-id-2", modTime)) + }, + group: "management", + kind: management.APIServiceGVK().Kind, + scopeName: "env1", + expectLen: 1, + expectKeys: []string{ResourceCacheKey(management.APIServiceGVK().Kind, "env1", "svc1")}, + }, + "watch resource scope filter": { + setup: func(cm Manager) { + cm.AddWatchResource(makeRI("catalog", "SomeKind", "Environment", "env1", "res1", "id1", modTime)) + cm.AddWatchResource(makeRI("catalog", "SomeKind", "Environment", "env2", "res2", "id2", modTime)) + }, + group: "catalog", + kind: "SomeKind", + scopeName: "env1", + expectLen: 1, + expectKeys: []string{ResourceCacheKey("SomeKind", "env1", "res1")}, + }, + "empty cache": { + group: "management", + kind: management.APIServiceGVK().Kind, + expectLen: 0, + }, + "unknown kind falls back to watch resource": { + setup: func(cm Manager) { + cm.AddWatchResource(makeRI("catalog", "SomeCustomKind", "Environment", "env1", "custom1", "id1", modTime)) + }, + group: "catalog", + kind: "SomeCustomKind", + expectLen: 1, + expectKeys: []string{ResourceCacheKey("SomeCustomKind", "env1", "custom1")}, + }, + "watch resource filters by group and kind - group A": { + setup: func(cm Manager) { + cm.AddWatchResource(makeRI("groupA", "KindA", "", "", "res1", "id1", modTime)) + cm.AddWatchResource(makeRI("groupB", "KindB", "", "", "res2", "id2", modTime)) + }, + group: "groupA", + kind: "KindA", + expectLen: 1, + expectKeys: []string{ResourceCacheKey("KindA", "", "res1")}, + }, + "watch resource filters by group and kind - group B": { + setup: func(cm Manager) { + cm.AddWatchResource(makeRI("groupA", "KindA", "", "", "res1", "id1", modTime)) + cm.AddWatchResource(makeRI("groupB", "KindB", "", "", "res2", "id2", modTime)) + }, + group: "groupB", + kind: "KindB", + expectLen: 1, + expectKeys: []string{ResourceCacheKey("KindB", "", "res2")}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := NewAgentCacheManager(&config.CentralConfiguration{}, false) + if tc.setup != nil { + tc.setup(cm) + } + result := cm.GetCachedResourcesByKind(tc.group, tc.kind, tc.scopeName) + assert.Len(t, result, tc.expectLen) + for _, k := range tc.expectKeys { + assert.Contains(t, result, k) + } + if tc.verify != nil { + tc.verify(t, result) + } + }) + } +} + +func TestFlushKind(t *testing.T) { + modTime := time.Date(2026, 3, 12, 10, 0, 0, 0, time.UTC) + + tests := map[string]struct { + setup func(Manager) + flushKind string + // after flush, verify these kinds still have data + expectNonEmpty []struct{ group, kind string } + // after flush, verify these kinds are empty + expectEmpty []struct{ group, kind string } + }{ + "flush APIService leaves other caches intact": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + cm.AddAPIServiceInstance(makeRI("management", management.APIServiceInstanceGVK().Kind, "Environment", "env1", "inst1", "id1", modTime)) + }, + flushKind: management.APIServiceGVK().Kind, + expectEmpty: []struct{ group, kind string }{ + {"management", management.APIServiceGVK().Kind}, + }, + expectNonEmpty: []struct{ group, kind string }{ + {"management", management.APIServiceInstanceGVK().Kind}, + }, + }, + "flush APIServiceInstance": { + setup: func(cm Manager) { + cm.AddAPIServiceInstance(makeRI("management", management.APIServiceInstanceGVK().Kind, "Environment", "env1", "inst1", "id1", modTime)) + }, + flushKind: management.APIServiceInstanceGVK().Kind, + expectEmpty: []struct{ group, kind string }{ + {"management", management.APIServiceInstanceGVK().Kind}, + }, + }, + "flush ManagedApplication": { + setup: func(cm Manager) { + cm.AddManagedApplication(makeRI("management", management.ManagedApplicationGVK().Kind, "Environment", "env1", "app1", "id1", modTime)) + }, + flushKind: management.ManagedApplicationGVK().Kind, + expectEmpty: []struct{ group, kind string }{ + {"management", management.ManagedApplicationGVK().Kind}, + }, + }, + "flush unknown kind is a no-op": { + setup: func(cm Manager) { + cm.AddAPIService(makeAPIServiceRI("env1", "svc1", "ext-id-1", modTime)) + }, + flushKind: "UnknownKind", + expectNonEmpty: []struct{ group, kind string }{ + {"management", management.APIServiceGVK().Kind}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := NewAgentCacheManager(&config.CentralConfiguration{}, false) + if tc.setup != nil { + tc.setup(cm) + } + + cm.FlushKind(tc.flushKind) + + for _, e := range tc.expectEmpty { + result := cm.GetCachedResourcesByKind(e.group, e.kind, "") + assert.Empty(t, result, "expected %s cache to be empty after flush", e.kind) + } + for _, e := range tc.expectNonEmpty { + result := cm.GetCachedResourcesByKind(e.group, e.kind, "") + assert.NotEmpty(t, result, "expected %s cache to still have data", e.kind) + } + }) + } +} diff --git a/pkg/agent/cache/loaders.go b/pkg/agent/cache/loaders.go index 100f31f53..5f8271dfa 100644 --- a/pkg/agent/cache/loaders.go +++ b/pkg/agent/cache/loaders.go @@ -38,24 +38,6 @@ func (resourceLoader) unmarshaller(data []byte) (interface{}, error) { return ri, nil } -func createInstanceCountLoader(setter func(cache.Cache, string), key string) *instanceCountLoader { - return &instanceCountLoader{createResourceLoader(setter, key)} -} - -// instanceCountLoader -type instanceCountLoader struct { - *resourceLoader -} - -func (l *instanceCountLoader) unmarshaller(data []byte) (interface{}, error) { - c := apiServiceToInstanceCount{} - err := json.Unmarshal(data, &c) - if err != nil { - return nil, err - } - return c, nil -} - func createSequenceLoader(setter func(cache.Cache, string), key string) *sequenceLoader { return &sequenceLoader{createResourceLoader(setter, key)} } diff --git a/pkg/agent/cache/manager.go b/pkg/agent/cache/manager.go index 639102743..0141bfe0d 100644 --- a/pkg/agent/cache/manager.go +++ b/pkg/agent/cache/manager.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "sync" + "time" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -21,13 +22,11 @@ const defaultCacheStoragePath = "./data/cache" const ( apiServicesKey = "apiServices" apiServiceInstancesKey = "apiServiceInstances" - instanceCountKey = "instanceCount" credReqDefKey = "credReqDef" accReqDefKey = "accReqDef" appProfDefKey = "appProfDef" teamsKey = "teams" managedAppKey = "managedApp" - subscriptionsKey = "subscriptions" accReqKey = "accReq" idpMetadataKey = "idpMetadata" watchSequenceKey = "watchSequence" @@ -42,6 +41,7 @@ type Manager interface { HasLoadedPersistedCache() bool SaveCache() Flush() + FlushKind(kind string) // API Service cache related methods AddAPIService(resource *v1.ResourceInstance) error @@ -136,6 +136,8 @@ type Manager interface { GetWatchResourceByName(group, kind, name string) *v1.ResourceInstance DeleteWatchResource(group, kind, id string) error + GetCachedResourcesByKind(group, kind, scopeName string) map[string]time.Time + ApplyResourceReadLock() ReleaseResourceReadLock() } @@ -150,13 +152,11 @@ type cacheManager struct { jobs.Job logger log.FieldLogger apiMap cache.Cache - instanceCountMap cache.Cache instanceMap cache.Cache managedApplicationMap cache.Cache accessRequestMap cache.Cache watchResourceMap cache.Cache idpMetadataMap cache.Cache - subscriptionMap cache.Cache sequenceCache cache.Cache resourceCacheReadLock sync.Mutex cacheLock sync.Mutex @@ -205,10 +205,8 @@ func (c *cacheManager) initializeCache(cfg config.CentralConfig) { createResourceLoader(c.setLoadedCache, accReqDefKey), createResourceLoader(c.setLoadedCache, appProfDefKey), createResourceLoader(c.setLoadedCache, managedAppKey), - createResourceLoader(c.setLoadedCache, subscriptionsKey), createResourceLoader(c.setLoadedCache, accReqKey), createResourceLoader(c.setLoadedCache, watchResourceKey), - createInstanceCountLoader(c.setLoadedCache, instanceCountKey), createTeamLoader(c.setLoadedCache, teamsKey), createSequenceLoader(c.setLoadedCache, watchSequenceKey), createResourceLoader(c.setLoadedCache, complianceRuntimeKey), @@ -250,8 +248,6 @@ func (c *cacheManager) setLoadedCache(lc cache.Cache, key string) { c.apiMap = lc case apiServiceInstancesKey: c.instanceMap = lc - case instanceCountKey: - c.instanceCountMap = lc case credReqDefKey: c.crdMap = lc case accReqDefKey: @@ -262,8 +258,6 @@ func (c *cacheManager) setLoadedCache(lc cache.Cache, key string) { c.teams = lc case managedAppKey: c.managedApplicationMap = lc - case subscriptionsKey: - c.subscriptionMap = lc case accReqKey: c.accessRequestMap = lc case watchResourceKey: @@ -430,7 +424,6 @@ func (c *cacheManager) Flush() { c.instanceMap.Flush() c.managedApplicationMap.Flush() c.sequenceCache.Flush() - c.subscriptionMap.Flush() c.watchResourceMap.Flush() c.idpMetadataMap.Flush() c.SaveCache() diff --git a/pkg/agent/cachevalidationjob.go b/pkg/agent/cachevalidationjob.go new file mode 100644 index 000000000..5f8af0dab --- /dev/null +++ b/pkg/agent/cachevalidationjob.go @@ -0,0 +1,200 @@ +package agent + +import ( + "context" + "fmt" + "sync" + "time" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/agent/events" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/harvester" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +var errCacheOutOfSync = fmt.Errorf("persisted cache is out of sync with API server") + +type cacheGetter interface { + GetCachedResourcesByKind(group, kind, scopeName string) map[string]time.Time +} + +// cacheValidator validates the persisted cache against the API server +type cacheValidator struct { + logger log.FieldLogger + client resourceClient + watchTopic *management.WatchTopic + cacheMan cacheGetter + harvester harvester.Harvest + sequence events.SequenceProvider +} + +func newCacheValidator( + client resourceClient, + watchTopic *management.WatchTopic, + cacheMan cacheGetter, + hClient harvester.Harvest, + seq events.SequenceProvider, +) *cacheValidator { + logger := log.NewFieldLogger(). + WithPackage("sdk.agent"). + WithComponent("cacheValidator") + + return &cacheValidator{ + logger: logger, + client: client, + watchTopic: watchTopic, + cacheMan: cacheMan, + harvester: hClient, + sequence: seq, + } +} + +// Execute validates each filter in the watch topic against the API server. +// It returns the slice of filters whose cache is out of sync, plus errCacheOutOfSync +// if any failed, or a nil slice and nil error if all filters are in sync. +func (cv *cacheValidator) Execute() ([]management.WatchTopicSpecFilters, error) { + cv.logger.Debug("executing cache validation") + + seqInSync := cv.sequenceInSync() + if !seqInSync { + return nil, errCacheOutOfSync + } + + filters := cv.watchTopic.Spec.Filters + ch := make(chan management.WatchTopicSpecFilters, len(filters)) + + cachedFilters := make([]management.WatchTopicSpecFilters, 0, len(filters)) + var wg sync.WaitGroup + apiSvcFilter := management.WatchTopicSpecFilters{} + for _, filter := range filters { + if agentcache.IsCachedKind(filter.Kind) { + wg.Add(1) + cachedFilters = append(cachedFilters, filter) + if filter.Kind == management.APIServiceGVK().Kind { + apiSvcFilter = filter + } + go func(f management.WatchTopicSpecFilters) { + defer wg.Done() + if !cv.validateKind(f) { + ch <- f + } + }(filter) + } + } + wg.Wait() + close(ch) + + failed := make(map[string]management.WatchTopicSpecFilters) + for f := range ch { + failed[f.Kind] = f + } + + // APIService cache is keyed by external API ID instead of resource ID. + // Write APIService filter along with APIServiceInstance filter when APIServiceInstance counts are off. + _, ok := failed[management.APIServiceInstanceGVK().Kind] + if ok && apiSvcFilter.Kind != "" { + failed[apiSvcFilter.Kind] = apiSvcFilter + } + + if len(failed) > 0 { + filtersToProcess := make([]management.WatchTopicSpecFilters, 0, len(failed)) + for _, f := range failed { + filtersToProcess = append(filtersToProcess, f) + } + return filtersToProcess, errCacheOutOfSync + } + + cv.logger.Debug("cache validation passed") + return cachedFilters, nil +} + +// sequenceInSync fetches the latest sequence ID from the harvester and compares it +// with the locally stored sequence. Returns true when they match, allowing Execute +// to skip per-kind validation entirely. Returns false on any error or mismatch, +// logging the discrepancy so callers have context for the validation that follows. +func (cv *cacheValidator) sequenceInSync() bool { + if cv.harvester == nil || cv.sequence == nil { + return false + } + + serverSeq, err := cv.harvester.ReceiveSyncEvents( + context.Background(), cv.watchTopic.GetSelfLink(), 0, nil, + ) + if err != nil { + cv.logger.WithError(err).Debug("could not fetch latest harvester sequence, proceeding with per-kind validation") + return true + } + + cachedSeq := cv.sequence.GetSequence() + if serverSeq != cachedSeq { + cv.logger. + WithField("cachedSeq", cachedSeq). + WithField("serverSeq", serverSeq). + Info("sequence mismatch detected") + return false + } + + return true +} + +func (cv *cacheValidator) validateKind(filter management.WatchTopicSpecFilters) bool { + logger := cv.logger.WithField("kind", filter.Kind).WithField("group", filter.Group) + + ri := apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: apiv1.GroupVersionKind{ + GroupKind: apiv1.GroupKind{ + Group: filter.Group, + Kind: filter.Kind, + }, + APIVersion: "v1alpha1", + }, + }, + } + if filter.Scope != nil { + ri.Metadata.Scope.Kind = filter.Scope.Kind + ri.Metadata.Scope.Name = filter.Scope.Name + } + + url := ri.GetKindLink() + if url == "" { + logger.Trace("skipping validation, could not build resource URL") + return true + } + + scopeName := "" + if filter.Scope != nil { + scopeName = filter.Scope.Name + } + cachedResources := cv.cacheMan.GetCachedResourcesByKind(filter.Group, filter.Kind, scopeName) + + // Count check: if the number of resources in the cache does not match the number on the server, we know the cache is out of sync. + // Note: APIService cache with existing duplicates on server may always fail this check, as it gets cached based on externalAPIID and not the resource id. + serverCount, err := cv.client.GetAPIV1ResourceCount(url) + if err != nil { + logger.WithError(err).Error("HEAD request failed, falling back to full metadata fetch") + return false + } + + if filter.Kind == management.APIServiceGVK().Kind { + if serverCount < len(cachedResources) { + logger. + WithField("serverCount", serverCount). + WithField("cacheCount", len(cachedResources)). + Info("cache validation: APIService count mismatch, cache is out of sync") + return false + } + return true + } + + if serverCount != len(cachedResources) { + logger. + WithField("serverCount", serverCount). + WithField("cacheCount", len(cachedResources)). + Info("cache validation: count mismatch, cache is out of sync") + return false + } + return true +} diff --git a/pkg/agent/cachevalidationjob_test.go b/pkg/agent/cachevalidationjob_test.go new file mode 100644 index 000000000..0e2f18d18 --- /dev/null +++ b/pkg/agent/cachevalidationjob_test.go @@ -0,0 +1,497 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/agent/events" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/harvester" + "github.com/Axway/agent-sdk/pkg/watchmanager/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockCacheGetter implements cacheGetter for tests +type mockCacheGetter struct { + resources map[string]map[string]time.Time // keyed by "group/kind" +} + +func newMockCacheGetter() *mockCacheGetter { + return &mockCacheGetter{ + resources: make(map[string]map[string]time.Time), + } +} + +func (m *mockCacheGetter) GetCachedResourcesByKind(group, kind, scopeName string) map[string]time.Time { + key := group + "/" + kind + res, ok := m.resources[key] + if !ok { + return make(map[string]time.Time) + } + if scopeName == "" { + return res + } + filtered := make(map[string]time.Time) + for k, v := range res { + // key format: kind/scopeName/name + parts := strings.SplitN(k, "/", 3) + if len(parts) == 3 && parts[1] == scopeName { + filtered[k] = v + } + } + return filtered +} + +func (m *mockCacheGetter) setResources(group, kind string, resources map[string]time.Time) { + m.resources[group+"/"+kind] = resources +} + +// mockResourceClient implements resourceClient for tests +type mockResourceClient struct { + resources map[string][]*apiv1.ResourceInstance // keyed by URL + err error +} + +func newMockResourceClient() *mockResourceClient { + return &mockResourceClient{ + resources: make(map[string][]*apiv1.ResourceInstance), + } +} + +func (m *mockResourceClient) GetAPIV1ResourceInstances(_ map[string]string, URL string) ([]*apiv1.ResourceInstance, error) { + if m.err != nil { + return nil, m.err + } + if res, ok := m.resources[URL]; ok { + return res, nil + } + return []*apiv1.ResourceInstance{}, nil +} + +func (m *mockResourceClient) GetAPIV1ResourceCount(URL string) (int, error) { + if m.err != nil { + return 0, m.err + } + return len(m.resources[URL]), nil +} + +// mockCVHarvester is a harvester mock with a configurable latest sequence ID, +// used to control the sequence pre-check inside cacheValidator. +type mockCVHarvester struct { + latestSeq int64 + err error +} + +func (m *mockCVHarvester) ReceiveSyncEvents(_ context.Context, _ string, _ int64, _ chan *proto.Event) (int64, error) { + return m.latestSeq, m.err +} + +func (m *mockCVHarvester) EventCatchUp(_ context.Context, _ string, _ chan *proto.Event) error { + return nil +} + +type mockSeqProvider struct { + seq int64 +} + +func (m *mockSeqProvider) GetSequence() int64 { return m.seq } +func (m *mockSeqProvider) SetSequence(id int64) { m.seq = id } + +func makeServerResource(kind, scopeKind, scopeName, name string, modTime time.Time) *apiv1.ResourceInstance { + return &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: apiv1.GroupVersionKind{ + GroupKind: apiv1.GroupKind{ + Group: "management", + Kind: kind, + }, + APIVersion: "v1alpha1", + }, + Metadata: apiv1.Metadata{ + Scope: apiv1.MetadataScope{ + Kind: scopeKind, + Name: scopeName, + }, + Audit: apiv1.AuditMetadata{ + ModifyTimestamp: apiv1.Time(modTime), + }, + }, + Name: name, + }, + } +} + +func TestCacheValidator_Execute(t *testing.T) { + modTime := time.Date(2026, 3, 12, 10, 0, 0, 0, time.UTC) + + svcGVK := management.APIServiceGVK() + instGVK := management.APIServiceInstanceGVK() + crrGVK := management.ComplianceRuntimeResultGVK() + managedAppGVK := management.ManagedApplicationGVK() + crdGVK := management.CredentialRequestDefinitionGVK() + scopeName := "testEnv" + + svc1 := makeServerResource(svcGVK.Kind, "Environment", scopeName, "svc1", modTime) + svc2 := makeServerResource(svcGVK.Kind, "Environment", scopeName, "svc2", modTime) + inst1 := makeServerResource(instGVK.Kind, "Environment", scopeName, "inst1", modTime) + crr1 := makeServerResource(crrGVK.Kind, "Environment", scopeName, "crr1", modTime) + managedApp1 := makeServerResource(managedAppGVK.Kind, "Environment", scopeName, "managedApp1", modTime) + crd1 := makeServerResource(crdGVK.Kind, "Environment", scopeName, "crd1", modTime) + + singleScopeWatchTopic := func(gvk apiv1.GroupVersionKind, scopeName string) *management.WatchTopic { + return &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + { + Group: gvk.Group, + Kind: gvk.Kind, + Name: "*", + Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}, + }, + }, + }, + } + } + + type testCase struct { + setup func(*mockResourceClient, *mockCacheGetter) + watchTopic *management.WatchTopic + harvester harvester.Harvest + sequence events.SequenceProvider + expectErr error + // expectedKinds is the set of kinds in the slice Execute returns: the + // out-of-sync (failed) kinds when expectErr is errCacheOutOfSync, or the + // validated (in-sync) kinds when expectErr is nil. + expectedKinds []string + } + + tests := map[string]testCase{ + "skips non-cached kinds": { + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: "management", Kind: management.WatchTopicGVK().Kind, Name: "*"}, + }, + }, + }, + expectErr: nil, + expectedKinds: nil, + }, + "in sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + "count mismatch": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1, svc2} + cacheMan.setResources(crrGVK.Group, crrGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(crrGVK.Kind, scopeName, "crr1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(crrGVK, scopeName), + expectErr: errCacheOutOfSync, + expectedKinds: []string{crrGVK.Kind}, + }, + // APIService count validation is intentionally relaxed: validateKind always + // returns true for APIService because the cache deduplicates by externalAPIID, + // so count mismatches are expected and not treated as out-of-sync. + "APIService higher count mismatch - not treated as out of sync will be treated with APIServiceInstances": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1, svc2} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + "extra resource in cache - count mismatch detected": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[crd1.GetKindLink()] = []*apiv1.ResourceInstance{crd1} + cacheMan.setResources(crdGVK.Group, crdGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(crdGVK.Kind, scopeName, "crd1"): modTime, + agentcache.ResourceCacheKey(crdGVK.Kind, scopeName, "crd2"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(crdGVK, scopeName), + expectErr: errCacheOutOfSync, + expectedKinds: []string{crdGVK.Kind}, + }, + // When the cache holds more APIService entries than the server, the count + // mismatch is logged but still not treated as out-of-sync (returns true). + "APIService extra resource in cache - still not treated as out of sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc3"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: errCacheOutOfSync, + expectedKinds: []string{svcGVK.Kind}, + }, + // Count-only validation cannot detect drift when the server and cache hold the + // same number of resources but different ones (metadata/timestamp checks were + // removed). This documents that limitation: a swapped resource is treated as in sync. + "same count, different resource is not detected by count-only validation": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc-other"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + "fetch error": { + setup: func(client *mockResourceClient, _ *mockCacheGetter) { + client.err = fmt.Errorf("network error") + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: errCacheOutOfSync, + expectedKinds: []string{svcGVK.Kind}, + }, + "APIServiceInstances from multiple scopes are each validated independently": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + instEnv1 := makeServerResource(instGVK.Kind, "Environment", "env1", "inst1", modTime) + instEnv2 := makeServerResource(instGVK.Kind, "Environment", "env2", "inst2", modTime) + client.resources[instEnv1.GetKindLink()] = []*apiv1.ResourceInstance{instEnv1} + client.resources[instEnv2.GetKindLink()] = []*apiv1.ResourceInstance{instEnv2} + cacheMan.setResources(instGVK.Group, instGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(instGVK.Kind, "env1", "inst1"): modTime, + agentcache.ResourceCacheKey(instGVK.Kind, "env2", "inst2"): modTime, + }) + }, + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: instGVK.Group, Kind: instGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: "env1"}}, + {Group: instGVK.Group, Kind: instGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: "env2"}}, + }, + }, + }, + expectErr: nil, + expectedKinds: []string{instGVK.Kind, instGVK.Kind}, + }, + "multiple kinds in sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + client.resources[inst1.GetKindLink()] = []*apiv1.ResourceInstance{inst1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + cacheMan.setResources(instGVK.Group, instGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(instGVK.Kind, scopeName, "inst1"): modTime, + }) + }, + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: svcGVK.Group, Kind: svcGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + {Group: instGVK.Group, Kind: instGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + }, + }, + }, + expectErr: nil, + expectedKinds: []string{svcGVK.Kind, instGVK.Kind}, + }, + "second kind out of sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[managedApp1.GetKindLink()] = []*apiv1.ResourceInstance{managedApp1} + client.resources[crd1.GetKindLink()] = []*apiv1.ResourceInstance{crd1} + cacheMan.setResources(managedAppGVK.Group, managedAppGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(managedAppGVK.Kind, scopeName, "managedApp1"): modTime, + }) + cacheMan.setResources(crdGVK.Group, crdGVK.Kind, map[string]time.Time{}) + }, + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: managedAppGVK.Group, Kind: managedAppGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + {Group: crdGVK.Group, Kind: crdGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + }, + }, + }, + expectErr: errCacheOutOfSync, + expectedKinds: []string{crdGVK.Kind}, + }, + // When APIServiceInstance is out of sync, the APIService filter is also + // included in the failed set (cache is keyed by externalAPIID, so the + // full rebuild must include both kinds). + "second kind out of sync - APIServiceInstance fetches in APIService": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + client.resources[inst1.GetKindLink()] = []*apiv1.ResourceInstance{inst1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + cacheMan.setResources(instGVK.Group, instGVK.Kind, map[string]time.Time{}) + }, + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: svcGVK.Group, Kind: svcGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + {Group: instGVK.Group, Kind: instGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + }, + }, + }, + expectErr: errCacheOutOfSync, + expectedKinds: []string{instGVK.Kind, svcGVK.Kind}, + }, + "both kinds out of sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + client.resources[inst1.GetKindLink()] = []*apiv1.ResourceInstance{inst1} + // both empty in cache + }, + watchTopic: &management.WatchTopic{ + Spec: management.WatchTopicSpec{ + Filters: []management.WatchTopicSpecFilters{ + {Group: svcGVK.Group, Kind: svcGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + {Group: instGVK.Group, Kind: instGVK.Kind, Name: "*", Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}}, + }, + }, + }, + expectErr: errCacheOutOfSync, + expectedKinds: []string{svcGVK.Kind, instGVK.Kind}, + }, + "empty server and cache": { + setup: func(client *mockResourceClient, _ *mockCacheGetter) { + ri := apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: apiv1.GroupVersionKind{ + GroupKind: apiv1.GroupKind{Group: svcGVK.Group, Kind: svcGVK.Kind}, + APIVersion: "v1alpha1", + }, + Metadata: apiv1.Metadata{ + Scope: apiv1.MetadataScope{Kind: "Environment", Name: scopeName}, + }, + }, + } + client.resources[ri.GetKindLink()] = []*apiv1.ResourceInstance{} + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + "ComplianceRuntimeResult out of sync is detected": { + setup: func(client *mockResourceClient, _ *mockCacheGetter) { + client.resources[crr1.GetKindLink()] = []*apiv1.ResourceInstance{crr1} + }, + watchTopic: singleScopeWatchTopic(crrGVK, scopeName), + expectErr: errCacheOutOfSync, + expectedKinds: []string{crrGVK.Kind}, + }, + // Sequence in sync: per-kind count validation runs and passes, returning the + // validated filters with no error. + "sequence in sync - per-kind validation passes": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + harvester: &mockCVHarvester{latestSeq: 10}, + sequence: &mockSeqProvider{seq: 10}, + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + // Sequence out of sync: Execute reports the cache as out of sync without + // running per-kind validation, returning no filters. + "sequence out of sync - reported out of sync without per-kind validation": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + harvester: &mockCVHarvester{latestSeq: 20}, + sequence: &mockSeqProvider{seq: 10}, + expectErr: errCacheOutOfSync, + expectedKinds: nil, + }, + // Harvester unreachable (ReceiveSyncEvents returns an error): Execute falls back + // to per-kind count validation instead of immediately reporting out of sync. + // Cache and server are in sync, so no error is returned. + "harvester unreachable - falls back to per-kind validation, cache in sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[svc1.GetKindLink()] = []*apiv1.ResourceInstance{svc1} + cacheMan.setResources(svcGVK.Group, svcGVK.Kind, map[string]time.Time{ + agentcache.ResourceCacheKey(svcGVK.Kind, scopeName, "svc1"): modTime, + }) + }, + watchTopic: singleScopeWatchTopic(svcGVK, scopeName), + harvester: &mockCVHarvester{err: fmt.Errorf("connection refused")}, + sequence: &mockSeqProvider{seq: 10}, + expectErr: nil, + expectedKinds: []string{svcGVK.Kind}, + }, + // Harvester unreachable and cache is out of sync: per-kind count validation + // runs and catches the mismatch, returning the failed filter. + // Uses APIServiceInstance (not APIService) because APIService count validation + // is intentionally relaxed and never reports out-of-sync. + "harvester unreachable - falls back to per-kind validation, cache out of sync": { + setup: func(client *mockResourceClient, cacheMan *mockCacheGetter) { + client.resources[inst1.GetKindLink()] = []*apiv1.ResourceInstance{inst1} + cacheMan.setResources(instGVK.Group, instGVK.Kind, map[string]time.Time{}) + }, + watchTopic: singleScopeWatchTopic(instGVK, scopeName), + harvester: &mockCVHarvester{err: fmt.Errorf("connection refused")}, + sequence: &mockSeqProvider{seq: 10}, + expectErr: errCacheOutOfSync, + expectedKinds: []string{instGVK.Kind}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client := newMockResourceClient() + cacheMan := newMockCacheGetter() + if tc.setup != nil { + tc.setup(client, cacheMan) + } + + // Default to an in-sync harvester/sequence so per-kind count validation + // runs. Cases that exercise the sequence pre-check set their own. + harv := tc.harvester + seq := tc.sequence + if harv == nil && seq == nil { + harv = &mockCVHarvester{latestSeq: 1} + seq = &mockSeqProvider{seq: 1} + } + + cv := newCacheValidator(client, tc.watchTopic, cacheMan, harv, seq) + filters, err := cv.Execute() + assert.Equal(t, tc.expectErr, err) + + if len(tc.expectedKinds) == 0 { + assert.Empty(t, filters) + } else { + require.Len(t, filters, len(tc.expectedKinds)) + actualKinds := make([]string, len(filters)) + for i, f := range filters { + actualKinds[i] = f.Kind + } + assert.ElementsMatch(t, tc.expectedKinds, actualKinds) + } + }) + } +} diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index c90ca8b91..35fdded2b 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -1,6 +1,8 @@ package agent import ( + "errors" + "github.com/Axway/agent-sdk/pkg/apic" apiV1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -116,25 +118,31 @@ func GetOwnerOnPublishedAPIByPrimaryKey(primaryKey string) *apiV1.Owner { } func PublishingLock() { + agent.publishingLockAcquired.Swap(true) agent.publishingLock.Lock() } func PublishingUnlock() { agent.publishingLock.Unlock() + agent.publishingLockAcquired.Swap(false) } // PublishAPI - Publishes the API func PublishAPI(serviceBody apic.ServiceBody) error { - if agent.apicClient != nil { - - var err error - _, err = publishAccessRequestDefinition(&serviceBody) - if err != nil { - return err - } + if !agent.publishingLockAcquired.Load() { + PublishingLock() + defer PublishingUnlock() + } + if agent.apicClient == nil { + return errors.New("apic client is not initialized") + } + var err error + _, err = publishAccessRequestDefinition(&serviceBody) + if err != nil { + return err } - _, err := agent.apicClient.PublishService(&serviceBody) + _, err = agent.apicClient.PublishService(&serviceBody) if err != nil { return err } diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go new file mode 100644 index 000000000..1e2b3ca24 --- /dev/null +++ b/pkg/agent/discovery_test.go @@ -0,0 +1,92 @@ +package agent + +import ( + "testing" + + "github.com/Axway/agent-sdk/pkg/apic" + "github.com/Axway/agent-sdk/pkg/apic/mock" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func setupDiscoveryTest(t *testing.T) func() { + t.Helper() + agent.cfg = createCentralCfg("http://test", "testEnv") + agent.publishingLockAcquired.Store(false) + return func() { + agent.apicClient = nil + agent.cfg = nil + agent.publishingLockAcquired.Store(false) + } +} + +func TestPublishingLockUnlock(t *testing.T) { + defer setupDiscoveryTest(t)() + + assert.False(t, agent.publishingLockAcquired.Load()) + + PublishingLock() + assert.True(t, agent.publishingLockAcquired.Load()) + + PublishingUnlock() + assert.False(t, agent.publishingLockAcquired.Load()) +} + +func TestPublishAPI_Locking(t *testing.T) { + tests := []struct { + name string + preAcquireLock bool + }{ + { + // PublishAPI acquires and releases the lock internally when the caller + // has not pre-acquired it. + name: "internal locking", + preAcquireLock: false, + }, + { + // PublishAPI must not deadlock when the caller has already acquired the + // lock. Before this fix, PublishAPI would attempt to re-acquire the + // non-reentrant mutex and deadlock. + name: "pre-acquired lock", + preAcquireLock: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer setupDiscoveryTest(t)() + + publishCalled := false + agent.apicClient = &mock.Client{ + PublishServiceMock: func(_ *apic.ServiceBody) (*management.APIService, error) { + publishCalled = true + return nil, nil + }, + } + + if tc.preAcquireLock { + PublishingLock() + assert.True(t, agent.publishingLockAcquired.Load()) + } else { + assert.False(t, agent.publishingLockAcquired.Load()) + } + + err := PublishAPI(apic.ServiceBody{}) + assert.Nil(t, err) + assert.True(t, publishCalled) + + if tc.preAcquireLock { + // flag remains true because the caller still holds the lock + assert.True(t, agent.publishingLockAcquired.Load()) + PublishingUnlock() + assert.False(t, agent.publishingLockAcquired.Load()) + } else { + // flag is not set when PublishAPI manages the lock internally + assert.False(t, agent.publishingLockAcquired.Load()) + // mutex must be released so it can be acquired again + assert.True(t, agent.publishingLock.TryLock(), "lock should be released after PublishAPI returns") + agent.publishingLock.Unlock() + } + }) + } +} diff --git a/pkg/agent/discoverycache.go b/pkg/agent/discoverycache.go index c1185bb6e..2c946bfe1 100644 --- a/pkg/agent/discoverycache.go +++ b/pkg/agent/discoverycache.go @@ -28,6 +28,7 @@ type discoveryCache struct { type resourceClient interface { GetAPIV1ResourceInstances(query map[string]string, URL string) ([]*apiv1.ResourceInstance, error) + GetAPIV1ResourceCount(URL string) (int, error) } // discoverFunc is the func definition for discovering resources to cache @@ -81,12 +82,19 @@ func newDiscoveryCache( return dc } -// execute rebuilds the discovery cache -func (dc *discoveryCache) execute() error { +// execute rebuilds the discovery cache. +// When filters are provided only those kinds are rebuilt; with no arguments all +// filters from the watch topic are used. +func (dc *discoveryCache) execute(filters ...management.WatchTopicSpecFilters) error { dc.logger.Debug("executing discovery cache") - discoveryFuncs := dc.buildDiscoveryFuncs() - if dc.additionalDiscoveryFuncs != nil { + active := dc.watchTopic.Spec.Filters + if len(filters) > 0 { + active = filters + } + + discoveryFuncs := dc.buildDiscoveryFuncsForFilters(active) + if len(filters) == 0 && dc.additionalDiscoveryFuncs != nil { discoveryFuncs = append(discoveryFuncs, dc.additionalDiscoveryFuncs...) } @@ -103,7 +111,7 @@ func (dc *discoveryCache) execute() error { // Now do the marketplace discovery funcs as the other functions have completed // AccessRequest cache need the APIServiceInstance cache to be fully loaded. - marketplaceDiscoveryFuncs := dc.buildMarketplaceDiscoveryFuncs() + marketplaceDiscoveryFuncs := dc.buildMarketplaceDiscoveryFuncsForFilters(active) err = dc.executeDiscoveryFuncs(marketplaceDiscoveryFuncs) if err != nil { return err @@ -147,10 +155,10 @@ func (dc *discoveryCache) executeDiscoveryFuncs(discoveryFuncs []discoverFunc) e return nil } -func (dc *discoveryCache) buildDiscoveryFuncs() []discoverFunc { +func (dc *discoveryCache) buildDiscoveryFuncsForFilters(filters []management.WatchTopicSpecFilters) []discoverFunc { resources := make(map[string]discoverFunc) - for _, filter := range dc.watchTopic.Spec.Filters { + for _, filter := range filters { kind := filter.Kind scope := "" if filter.Scope != nil && filter.Scope.Name != "" { @@ -172,10 +180,10 @@ func (dc *discoveryCache) buildDiscoveryFuncs() []discoverFunc { return funcs } -func (dc *discoveryCache) buildMarketplaceDiscoveryFuncs() []discoverFunc { +func (dc *discoveryCache) buildMarketplaceDiscoveryFuncsForFilters(filters []management.WatchTopicSpecFilters) []discoverFunc { mpResources := make(map[string]discoverFunc) - for _, filter := range dc.watchTopic.Spec.Filters { + for _, filter := range filters { if isMPResource(filter.Kind) { f := dc.buildResourceFunc(filter) mpResources[filter.Kind] = f @@ -246,12 +254,19 @@ func (dc *discoveryCache) buildResourceFunc(filter management.WatchTopicSpecFilt logger := dc.logger.WithField("kind", filter.Kind) logger.Tracef("fetching %s and updating cache", filter.Kind) - resources, err := dc.client.GetAPIV1ResourceInstances(nil, ri.GetKindLink()) + url := ri.GetKindLink() + resources, err := dc.client.GetAPIV1ResourceInstances(nil, url) if err != nil { return fmt.Errorf("failed to fetch resources of kind %s: %s", filter.Kind, err) } - - return dc.handleResourcesList(resources) + agent.cacheManager.FlushKind(filter.Kind) + err = dc.handleResourcesList(resources) + if err != nil { + // restore the existing cache if the discovery fails + // TODO: - may be this is not needed as handleResourcesList handles each resource and return nil error. Need to verify if errors returned from handler needs to be taken care + return fmt.Errorf("failed to handle resources of kind %s: %s", filter.Kind, err) + } + return nil } } @@ -275,7 +290,7 @@ func (dc *discoveryCache) handleResourcesList(list []*apiv1.ResourceInstance) er if err := dc.handleResource(ri, action); err != nil { logger. WithError(err). - Error("failed to migrate resource") + Error("failed to handle resource") } } diff --git a/pkg/agent/discoverycache_test.go b/pkg/agent/discoverycache_test.go index 0bcf9f1a5..6448efe1d 100644 --- a/pkg/agent/discoverycache_test.go +++ b/pkg/agent/discoverycache_test.go @@ -19,54 +19,63 @@ import ( const envName = "mockEnv" func TestDiscoveryCache_execute(t *testing.T) { - tests := []struct { + tests := map[string]struct { agentType config.AgentType - name string + wt *management.WatchTopic + filters []management.WatchTopicSpecFilters // nil = full rebuild (no args) + withMigration bool svcCount int managedAppCount int accessReqCount int credCount int - withMigration bool - wt *management.WatchTopic }{ - { - name: "should fetch resources based on the watch topic", + "full rebuild with marketplace": { agentType: config.DiscoveryAgent, + wt: mpWatchTopic, svcCount: 2, managedAppCount: 2, accessReqCount: 2, credCount: 2, - wt: mpWatchTopic, }, - { - name: "should fetch resources and perform a migration", + "full rebuild with migration": { agentType: config.DiscoveryAgent, + wt: mpWatchTopic, + withMigration: true, svcCount: 2, managedAppCount: 2, accessReqCount: 2, credCount: 2, - withMigration: true, - wt: mpWatchTopic, }, - { - name: "should fetch resources based on the watch topic with marketplace disabled", - agentType: config.TraceabilityAgent, - svcCount: 2, - managedAppCount: 0, - accessReqCount: 0, - credCount: 0, - wt: watchTopicNoMP, + "full rebuild no marketplace": { + agentType: config.TraceabilityAgent, + wt: watchTopicNoMP, + svcCount: 2, + }, + "filter subset - only APIService": { + agentType: config.DiscoveryAgent, + wt: mpWatchTopic, + filters: []management.WatchTopicSpecFilters{ + { + Group: management.APIServiceGVK().Group, + Kind: management.APIServiceGVK().Kind, + Name: "*", + Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: envName}, + }, + }, + svcCount: 2, + // managedAppCount, accessReqCount, credCount all default to 0 }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for name, tc := range tests { + t.Run(name, func(t *testing.T) { cfg := createCentralCfg("apicentral.axway.com", envName) cfg.AgentType = tc.agentType agent.cacheManager = agentcache.NewAgentCacheManager(agent.cfg, false) agent.cfg = cfg Initialize(cfg) scopeName := agent.cfg.GetEnvironmentName() + c := &mockRIClient{ svcs: newAPIServices(scopeName), managedApps: newManagedApps(scopeName), @@ -74,34 +83,15 @@ func TestDiscoveryCache_execute(t *testing.T) { accessReqs: newAccessReqs(scopeName), creds: newCredentials(scopeName), } - svcHandler := &mockHandler{ - kind: management.APIServiceGVK().Kind, - } - managedAppHandler := &mockHandler{ - kind: management.ManagedApplicationGVK().Kind, - } - managedAppProfHandler := &mockHandler{ - kind: management.ManagedApplicationProfileGVK().Kind, - } - accessReqHandler := &mockHandler{ - kind: management.AccessRequestGVK().Kind, - } - credHandler := &mockHandler{ - kind: management.CredentialGVK().Kind, - } - handlers := []handler.Handler{ - svcHandler, - managedAppHandler, - managedAppProfHandler, - accessReqHandler, - credHandler, - } + svcHandler := &mockHandler{kind: management.APIServiceGVK().Kind} + managedAppHandler := &mockHandler{kind: management.ManagedApplicationGVK().Kind} + managedAppProfHandler := &mockHandler{kind: management.ManagedApplicationProfileGVK().Kind} + accessReqHandler := &mockHandler{kind: management.AccessRequestGVK().Kind} + credHandler := &mockHandler{kind: management.CredentialGVK().Kind} opts := []discoveryOpt{ - withAdditionalDiscoverFuncs(func() error { - return nil - }), + withAdditionalDiscoverFuncs(func() error { return nil }), } migration := &mockMigrator{mutex: sync.Mutex{}} @@ -109,15 +99,13 @@ func TestDiscoveryCache_execute(t *testing.T) { opts = append(opts, withMigration(migration)) } - dc := newDiscoveryCache( - cfg, - c, - handlers, + dc := newDiscoveryCache(cfg, c, + []handler.Handler{svcHandler, managedAppHandler, managedAppProfHandler, accessReqHandler, credHandler}, tc.wt, opts..., ) - err := dc.execute() + err := dc.execute(tc.filters...) assert.Nil(t, err) assert.Equal(t, tc.svcCount, svcHandler.count) assert.Equal(t, tc.managedAppCount, managedAppHandler.count) @@ -130,7 +118,6 @@ func TestDiscoveryCache_execute(t *testing.T) { } }) } - } type mockHandler struct { @@ -196,6 +183,8 @@ type mockRIClient struct { err error } +func (m mockRIClient) GetAPIV1ResourceCount(_ string) (int, error) { return 0, nil } + func (m mockRIClient) GetAPIV1ResourceInstances(_ map[string]string, URL string) ([]*apiv1.ResourceInstance, error) { fmt.Println(URL) if strings.Contains(URL, "apiservices") { diff --git a/pkg/agent/events/eventlistener.go b/pkg/agent/events/eventlistener.go index 12bef629a..c29c994d1 100644 --- a/pkg/agent/events/eventlistener.go +++ b/pkg/agent/events/eventlistener.go @@ -91,8 +91,8 @@ func (em *EventListener) start() (done bool, err error) { break } - if err := em.handleEvent(event); err != nil { - em.logger.WithError(err).Error("stream event listener error handling event") + if handleErr := em.handleEvent(event); handleErr != nil { + em.logger.WithError(handleErr).Error("stream event listener error handling event") } case <-em.ctx.Done(): em.logger.Trace("stream event listener context is done") diff --git a/pkg/agent/events/eventlistener_test.go b/pkg/agent/events/eventlistener_test.go index bf23e7822..df3e2e057 100644 --- a/pkg/agent/events/eventlistener_test.go +++ b/pkg/agent/events/eventlistener_test.go @@ -3,7 +3,9 @@ package events import ( "context" "fmt" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" @@ -194,3 +196,30 @@ type mockHandler struct { func (m *mockHandler) Handle(_ context.Context, _ *proto.EventMeta, _ *apiv1.ResourceInstance) error { return m.err } + +// slowHandler blocks for the given duration each time Handle is called, +// and atomically increments callCount. +type slowHandler struct { + delay time.Duration + callCount atomic.Int32 +} + +func (h *slowHandler) Handle(_ context.Context, _ *proto.EventMeta, _ *apiv1.ResourceInstance) error { + time.Sleep(h.delay) + h.callCount.Add(1) + return nil +} + +func newTestEvent(seqID int64) *proto.Event { + return &proto.Event{ + Type: proto.Event_CREATED, + Payload: &proto.ResourceInstance{ + Metadata: &proto.Metadata{ + SelfLink: "/management/v1alpha1/watchtopics/mock-watch-topic", + }, + }, + Metadata: &proto.EventMeta{ + SequenceID: seqID, + }, + } +} diff --git a/pkg/agent/eventsjob.go b/pkg/agent/eventsjob.go index f8ade42ff..6399d99e8 100644 --- a/pkg/agent/eventsjob.go +++ b/pkg/agent/eventsjob.go @@ -1,6 +1,7 @@ package agent import ( + "context" "sync/atomic" "time" @@ -22,6 +23,7 @@ type eventsJob interface { Status() error Stop() Healthcheck(_ string) *hc.Status + WaitForReady(ctx context.Context) error } // eventProcessorJob job wrapper for a streamerClient that starts a stream and an event manager. diff --git a/pkg/agent/eventsync.go b/pkg/agent/eventsync.go index d6cf77cb5..ccbc09c9d 100644 --- a/pkg/agent/eventsync.go +++ b/pkg/agent/eventsync.go @@ -3,7 +3,6 @@ package agent import ( "context" "fmt" - "math" "strconv" "time" @@ -24,6 +23,12 @@ type EventSync struct { sequence events.SequenceProvider harvester harvester.Harvest discoveryCache *discoveryCache + cacheValidator CacheValidator +} + +// CacheValidator is satisfied by cacheValidator; allows EventSync to run cache validation. +type CacheValidator interface { + Execute() ([]management.WatchTopicSpecFilters, error) } // newEventSync creates an EventSync @@ -60,7 +65,7 @@ func newEventSync() (*EventSync, error) { sequence := events.NewSequenceProvider(agent.cacheManager, wt.Name) hCfg := harvester.NewConfig(agent.cfg, agent.tokenRequester, sequence) - hClient := harvester.NewClient(hCfg) + hClient := harvester.NewClient(hCfg, harvester.WithPublishLock(PublishingLock, PublishingUnlock)) discoveryCache := newDiscoveryCache( agent.cfg, @@ -70,11 +75,20 @@ func newEventSync() (*EventSync, error) { opts..., ) + cacheValidator := newCacheValidator( + GetCentralClient(), + wt, + agent.cacheManager, + hClient, + sequence, + ) + return &EventSync{ watchTopic: wt, sequence: sequence, harvester: hClient, discoveryCache: discoveryCache, + cacheValidator: cacheValidator, }, nil } @@ -85,10 +99,19 @@ func (es *EventSync) SyncCache() error { return err } } else { - err := finalizeInitialization() + // Validate the persisted cache against the API server + failedFilters, err := es.validateCache() if err != nil { - logger.WithError(err).Error("error finalizing setup prior to marketplace resource syncing") - return err + logger.WithError(err).Info("persisted cache validation failed, rebuilding out-of-sync kinds") + if err := es.initCache(failedFilters...); err != nil { + return err + } + } else { + err := finalizeInitialization() + if err != nil { + logger.WithError(err).Error("error finalizing setup prior to marketplace resource syncing") + return err + } } } @@ -114,67 +137,81 @@ func (es *EventSync) registerInstanceValidator() error { return nil } -func (es *EventSync) initCache() error { +func (es *EventSync) initCache(failedFilters ...management.WatchTopicSpecFilters) error { seqID, err := es.harvester.ReceiveSyncEvents(context.Background(), es.watchTopic.GetSelfLink(), 0, nil) if err != nil { return err } - // event channel is not ready yet, so subtract one from the latest sequence id to process the event - // when the poll/stream client is ready + // when no events returned by harvester the seqID will be 0, so not updated in sequence manager - agent.cacheManager.Flush() if seqID > 0 { - es.sequence.SetSequence(seqID - 1) + es.sequence.SetSequence(seqID) } - err = es.discoveryCache.execute() - if err != nil { - // flush cache again to clear out anything that may have been saved before the error to ensure a clean state for the next time through - agent.cacheManager.Flush() + + defer agent.cacheManager.SaveCache() + if err = es.discoveryCache.execute(failedFilters...); err != nil { return err } - agent.cacheManager.SaveCache() - agentInstance := agent.agentResourceManager.GetAgentResource() + es.resetCacheTimer() + return nil +} - // add 7 days to the current date for the next rebuild cache +// resetCacheTimer persists a new cacheUpdateTime 7 days from now in the agent's x-agent-details. +func (es *EventSync) resetCacheTimer() { + agentInstance := agent.agentResourceManager.GetAgentResource() nextCacheUpdateTime := time.Now().Add(7 * 24 * time.Hour) - - // persist cacheUpdateTime util.SetAgentDetailsKey(agentInstance, "cacheUpdateTime", strconv.FormatInt(nextCacheUpdateTime.UnixNano(), 10)) agent.apicClient.CreateSubResource(agentInstance.ResourceMeta, util.GetSubResourceDetails(agentInstance)) logger.Tracef("setting next cache update time to - %s", time.Unix(0, nextCacheUpdateTime.UnixNano()).Format("2006-01-02 15:04:05.000000")) - return nil } -func (es *EventSync) RebuildCache() { - // SDB - NOTE : Do we need to pause jobs. +// RebuildCache is the single entry point for all cache rebuilds. +// When filters are given it attempts a targeted rebuild first, falling back to full on error. +// The 7-day timer is reset on success. +func (es *EventSync) RebuildCache(filters ...management.WatchTopicSpecFilters) error { logger.Info("rebuild cache") // close window so discovery doesn't happen during this cache rebuild PublishingLock() defer PublishingUnlock() - es.waitForCacheRebuild() + return es.initCache(filters...) } -// waitForCacheRebuild continuously attempts to rebuild the cache until successful -func (es *EventSync) waitForCacheRebuild() { - adjustment := 2 - maxBackoff := 5 * time.Minute - currentBackoff := 30 * time.Second - for { - err := es.initCache() - if err == nil { - return - } +// validateCache runs the cache validator to check if the persisted cache is in sync. +// Returns the filters that are out of sync, plus a non-nil error if any failed. +func (es *EventSync) validateCache() ([]management.WatchTopicSpecFilters, error) { + if es.cacheValidator == nil { + return nil, nil + } + return es.cacheValidator.Execute() +} - logger. - WithError(err). - WithField("waitTime", currentBackoff.String()). - Error("failed to rebuild cache, retrying after waitTime") - time.Sleep(currentBackoff) - currentBackoff = time.Duration(math.Min(float64(maxBackoff), float64(currentBackoff)*float64(adjustment))) +// ValidateCache validates the cache against the API server. +// Returns the out-of-sync filters and a non-nil error if any kind failed validation. +// If all kinds pass, the 7-day timer is reset. +func (es *EventSync) ValidateCache() ([]management.WatchTopicSpecFilters, error) { + failedFilters, err := es.validateCache() + if err == nil { + es.resetCacheTimer() } + return failedFilters, err +} + +// validateAndRebuildCache validates the cache and rebuilds only the out-of-sync kinds. +// Called when connection to Central is restored. +func (es *EventSync) validateAndRebuildCache() error { + if es.cacheValidator == nil { + return nil + } + + failedFilters, err := es.validateCache() + if err != nil { + logger.WithError(err).Info("cache validation failed on reconnect, rebuilding out-of-sync kinds") + return es.RebuildCache(failedFilters...) + } + return nil } func (es *EventSync) startCentralEventProcessor() error { @@ -192,8 +229,8 @@ func (es *EventSync) startPollMode() error { agent.cfg, handlers, poller.WithHarvester(es.harvester, es.sequence, es.watchTopic.GetSelfLink()), - poller.WithOnClientStop(es.RebuildCache), poller.WithOnConnect(), + poller.WithOnReconnect(es.validateAndRebuildCache), ) if err != nil { @@ -202,9 +239,14 @@ func (es *EventSync) startPollMode() error { if util.IsNotTest() { newEventProcessorJob(pc, "Poll Client") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := pc.WaitForReady(ctx); err != nil { + return fmt.Errorf("poll client did not connect to Central within timeout: %w", err) + } } - return err + return nil } func (es *EventSync) startStreamMode() error { @@ -216,7 +258,7 @@ func (es *EventSync) startStreamMode() error { agent.tokenRequester, handlers, stream.WithOnStreamConnection(), - stream.WithEventSyncError(es.RebuildCache), + stream.WithOnReconnect(es.validateAndRebuildCache), stream.WithWatchTopic(es.watchTopic), stream.WithHarvester(es.harvester, es.sequence), stream.WithCacheManager(agent.cacheManager), @@ -231,7 +273,12 @@ func (es *EventSync) startStreamMode() error { if util.IsNotTest() { newEventProcessorJob(sc, "Stream Client") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := sc.WaitForReady(ctx); err != nil { + return fmt.Errorf("stream client did not connect to Central within timeout: %w", err) + } } - return err + return nil } diff --git a/pkg/agent/eventsync_test.go b/pkg/agent/eventsync_test.go index 4bea68927..c8902603b 100644 --- a/pkg/agent/eventsync_test.go +++ b/pkg/agent/eventsync_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/agent/handler" "github.com/Axway/agent-sdk/pkg/agent/resource" "github.com/Axway/agent-sdk/pkg/api" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" @@ -16,104 +18,216 @@ import ( "github.com/stretchr/testify/assert" ) -func TestEventSync_pollMode(t *testing.T) { - cfg := createCentralCfg("https://abc.com", "mockenv") - err := Initialize(cfg) - cfg.AgentName = "Test-DA" - agentRes := createDiscoveryAgentRes("111", "Test-DA", "test-dataplane", "") - - mc := &mock.Client{ - ExecuteAPIMock: func(method, url string, queryParam map[string]string, buffer []byte) ([]byte, error) { - if method == api.PUT { - return buffer, nil - } - return json.Marshal(agentRes) - }, - GetResourceMock: func(url string) (*apiv1.ResourceInstance, error) { - if strings.Contains(url, "/discoveryagents") { - return agentRes, nil - } - wt := management.NewWatchTopic("mock-wt") - ri, err := wt.AsInstance() - return ri, err - }, - } - - m, _ := resource.NewAgentResourceManager(cfg, mc, nil) - agent.agentResourceManager = m - - InitializeForTest(mc) - assert.Nil(t, err) - - es, err := newEventSync() - assert.Nil(t, err) - assert.NotNil(t, es.watchTopic) - assert.NotNil(t, es.discoveryCache) - assert.NotNil(t, es.sequence) - assert.NotNil(t, es.harvester) - - es.harvester = &mockHarvester{} - err = es.SyncCache() - assert.Nil(t, err) +func TestEventSync(t *testing.T) { + t.Run("syncCache", func(t *testing.T) { + tests := map[string]struct { + cfgFn func(cfg *config.CentralConfiguration) + agentFeatsCfg *config.AgentFeaturesConfiguration + }{ + "poll mode": {}, + "stream mode": { + cfgFn: func(cfg *config.CentralConfiguration) { + cfg.GRPCCfg = config.GRPCConfig{Enabled: true, Insecure: true} + }, + agentFeatsCfg: &config.AgentFeaturesConfiguration{ + ConnectToCentral: true, + ProcessSystemSignals: true, + VersionChecker: false, + PersistCache: true, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cfg := createCentralCfg("https://abc.com", "mockenv") + if tc.cfgFn != nil { + tc.cfgFn(cfg) + } + err := Initialize(cfg) + if tc.agentFeatsCfg != nil { + agent.agentFeaturesCfg = tc.agentFeatsCfg + } + + cfg.AgentName = "Test-DA" + agentRes := createDiscoveryAgentRes("111", "Test-DA", "test-dataplane", "") + + mc := &mock.Client{ + ExecuteAPIMock: func(method, url string, queryParam map[string]string, buffer []byte) ([]byte, error) { + if method == api.PUT { + return buffer, nil + } + return json.Marshal(agentRes) + }, + GetResourceMock: func(url string) (*apiv1.ResourceInstance, error) { + if strings.Contains(url, "/discoveryagents") { + return agentRes, nil + } + wt := management.NewWatchTopic("mock-wt") + ri, err := wt.AsInstance() + return ri, err + }, + } + + m, _ := resource.NewAgentResourceManager(cfg, mc, nil) + agent.agentResourceManager = m + InitializeForTest(mc) + assert.Nil(t, err) + + es, err := newEventSync() + assert.Nil(t, err) + assert.NotNil(t, es.watchTopic) + assert.NotNil(t, es.discoveryCache) + assert.NotNil(t, es.sequence) + assert.NotNil(t, es.harvester) + + es.harvester = &mockHarvester{} + err = es.SyncCache() + assert.Nil(t, err) + }) + } + }) + + t.Run("initCache", func(t *testing.T) { + cfg := createCentralCfg("https://abc.com", "mockenv") + err := Initialize(cfg) + assert.Nil(t, err) + cfg.AgentName = "Test-DA" + agentRes := createDiscoveryAgentRes("111", "Test-DA", "test-dataplane", "") + + mc := &mock.Client{ + ExecuteAPIMock: func(method, url string, queryParam map[string]string, buffer []byte) ([]byte, error) { + if method == api.PUT { + return buffer, nil + } + return json.Marshal(agentRes) + }, + GetResourceMock: func(url string) (*apiv1.ResourceInstance, error) { + if strings.Contains(url, "/discoveryagents") { + return agentRes, nil + } + wt := management.NewWatchTopic("mock-wt") + ri, err := wt.AsInstance() + return ri, err + }, + } + + m, _ := resource.NewAgentResourceManager(cfg, mc, nil) + agent.agentResourceManager = m + InitializeForTest(mc) + + scopeName := cfg.Environment + + apiSvcFilter := management.WatchTopicSpecFilters{ + Group: management.APIServiceGVK().Group, + Kind: management.APIServiceGVK().Kind, + Name: "*", + Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}, + } + instFilter := management.WatchTopicSpecFilters{ + Group: management.APIServiceInstanceGVK().Group, + Kind: management.APIServiceInstanceGVK().Kind, + Name: "*", + Scope: &management.WatchTopicSpecScope{Kind: "Environment", Name: scopeName}, + } + + tests := map[string]struct { + setup func() + wtFilters []management.WatchTopicSpecFilters + failedFilters []management.WatchTopicSpecFilters + makeClient func() resourceClient + expectedCount int + verify func(t *testing.T) + }{ + "targeted rebuild flushes only failed kind and rebuilds it": { + setup: func() { + inst1, _ := management.NewAPIServiceInstance("inst1", scopeName).AsInstance() + agent.cacheManager.AddAPIServiceInstance(inst1) + }, + wtFilters: []management.WatchTopicSpecFilters{apiSvcFilter, instFilter}, + failedFilters: []management.WatchTopicSpecFilters{apiSvcFilter}, + makeClient: func() resourceClient { + return &mockRIClient{svcs: newAPIServices(scopeName)} + }, + expectedCount: 2, + verify: func(t *testing.T) { + // Instance cache was NOT flushed — the instance we added before should still be there + cachedInst, instErr := agent.cacheManager.GetAPIServiceInstanceByName("inst1") + assert.Nil(t, instErr) + assert.NotNil(t, cachedInst) + }, + }, + "no filters - full rebuild": { + wtFilters: []management.WatchTopicSpecFilters{apiSvcFilter}, + makeClient: func() resourceClient { + return &mockRIClient{svcs: newAPIServices(scopeName)} + }, + expectedCount: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + agent.cacheManager = agentcache.NewAgentCacheManager(cfg, false) + if tc.setup != nil { + tc.setup() + } + + svcHandler := &mockHandler{kind: management.APIServiceGVK().Kind} + wt := &management.WatchTopic{Spec: management.WatchTopicSpec{Filters: tc.wtFilters}} + dc := newDiscoveryCache(cfg, tc.makeClient(), []handler.Handler{svcHandler}, wt) + + es := &EventSync{ + watchTopic: wt, + harvester: &mockHarvester{}, + discoveryCache: dc, + sequence: &mockSequence{}, + } + + err := es.initCache(tc.failedFilters...) + assert.Nil(t, err) + assert.Equal(t, tc.expectedCount, svcHandler.count) + + if tc.verify != nil { + tc.verify(t) + } + }) + } + }) } -func TestEventSync_streamMode(t *testing.T) { - cfg := createCentralCfg("https://abc.com", "mockenv") - cfg.GRPCCfg = config.GRPCConfig{ - Enabled: true, - Insecure: true, - } - err := Initialize(cfg) - agent.agentFeaturesCfg = &config.AgentFeaturesConfiguration{ - ConnectToCentral: true, - ProcessSystemSignals: true, - VersionChecker: false, - PersistCache: true, - } - - cfg.AgentName = "Test-DA" - agentRes := createDiscoveryAgentRes("111", "Test-DA", "test-dataplane", "") - mc := &mock.Client{ - ExecuteAPIMock: func(method, url string, queryParam map[string]string, buffer []byte) ([]byte, error) { - if method == api.PUT { - return buffer, nil - } - return json.Marshal(agentRes) - }, - GetResourceMock: func(url string) (*apiv1.ResourceInstance, error) { - if strings.Contains(url, "/discoveryagents") { - return agentRes, nil - } - wt := management.NewWatchTopic("mock-wt") - ri, err := wt.AsInstance() - return ri, err - }, - } - - m, _ := resource.NewAgentResourceManager(cfg, mc, nil) - agent.agentResourceManager = m - - InitializeForTest(mc) - assert.Nil(t, err) - - es, err := newEventSync() - assert.Nil(t, err) - assert.NotNil(t, es.watchTopic) - assert.NotNil(t, es.discoveryCache) - assert.NotNil(t, es.sequence) - assert.NotNil(t, es.harvester) - - es.harvester = &mockHarvester{} - err = es.SyncCache() - assert.Nil(t, err) +type mockHarvester struct { + err error } -type mockHarvester struct{} - func (m mockHarvester) EventCatchUp(ctx context.Context, link string, events chan *proto.Event) error { - return nil + return m.err } func (m mockHarvester) ReceiveSyncEvents(ctx context.Context, topicSelfLink string, sequenceID int64, eventCh chan *proto.Event) (int64, error) { - return 1, nil + return 1, m.err +} + +type mockSequence struct { + id int64 +} + +func (m *mockSequence) SetSequence(id int64) { + m.id = id +} + +func (m *mockSequence) GetSequence() int64 { + return m.id +} + +// mockCacheValidator implements CacheValidator for testing. +type mockCacheValidator struct { + failedFilters []management.WatchTopicSpecFilters + err error } + +func (m *mockCacheValidator) Execute() ([]management.WatchTopicSpecFilters, error) { + return m.failedFilters, m.err +} + +var _ CacheValidator = (*mockCacheValidator)(nil) diff --git a/pkg/agent/handler/agentresource_test.go b/pkg/agent/handler/agentresource_test.go index 354ea7448..e54392d5c 100644 --- a/pkg/agent/handler/agentresource_test.go +++ b/pkg/agent/handler/agentresource_test.go @@ -124,7 +124,8 @@ func (m *mockApicClient) CreateSubResource(_ v1.ResourceMeta, sub map[string]int } type EventSyncCache interface { - RebuildCache() + RebuildCache(filters ...management.WatchTopicSpecFilters) + ValidateCache() ([]management.WatchTopicSpecFilters, error) } type mockResourceManager struct { diff --git a/pkg/agent/handler/apiservice.go b/pkg/agent/handler/apiservice.go index ff50c3652..abc99b453 100644 --- a/pkg/agent/handler/apiservice.go +++ b/pkg/agent/handler/apiservice.go @@ -57,6 +57,21 @@ func (h *apiSvcHandler) Handle(ctx context.Context, _ *proto.EventMeta, resource return err } + existing, _ := h.agentCacheManager.GetAPIServiceCache().Get(resource.Name) + if existing == nil { + existing, _ = h.agentCacheManager.GetAPIServiceCache().GetBySecondaryKey(resource.Name) + } + existingSvc, ok := existing.(*apiv1.ResourceInstance) + if !ok { + log.Debug("invalid resource type in cache, skipping delete") + return nil + } + + if existingSvc.Metadata.ID != resource.Metadata.ID { + log.Debug("resource id mismatch, skipping delete") + return nil + } + // remove the service from the cache by name return h.agentCacheManager.DeleteAPIService(resource.Name) } diff --git a/pkg/agent/handler/discoveryaccessrequest.go b/pkg/agent/handler/discoveryaccessrequest.go new file mode 100644 index 000000000..5d81c19a4 --- /dev/null +++ b/pkg/agent/handler/discoveryaccessrequest.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/watchmanager/proto" +) + +type discoveryAccessRequest struct { + marketplaceHandler + cache agentcache.Manager +} + +// NewDiscoveryAccessRequestHandler creates a Handler for AccessRequests for discovery agent cache building +func NewDiscoveryAccessRequestHandler(cache agentcache.Manager) Handler { + return &discoveryAccessRequest{ + cache: cache, + } +} + +// Handle processes events triggered for AccessRequests during discovery cache building +func (h *discoveryAccessRequest) Handle(ctx context.Context, _ *proto.EventMeta, resource *apiv1.ResourceInstance) error { + action := GetActionFromContext(ctx) + if resource.Kind != management.AccessRequestGVK().Kind { + return nil + } + + if action == proto.Event_DELETED { + return h.cache.DeleteAccessRequest(resource.Metadata.ID) + } + + ar := &management.AccessRequest{} + err := ar.FromInstance(resource) + if err != nil { + return err + } + + ok := isStatusFound(ar.Status) + if !ok { + return nil + } + + if h.shouldProcessForAgent(ar.Status, ar.Metadata.State) { + cachedAccessReq := h.cache.GetAccessRequest(resource.Metadata.ID) + if cachedAccessReq == nil { + h.cache.AddAccessRequest(resource) + } + } + return nil +} diff --git a/pkg/agent/handler/discoveryaccessrequest_test.go b/pkg/agent/handler/discoveryaccessrequest_test.go new file mode 100644 index 000000000..8c46dd12e --- /dev/null +++ b/pkg/agent/handler/discoveryaccessrequest_test.go @@ -0,0 +1,237 @@ +package handler + +import ( + "testing" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/watchmanager/proto" + "github.com/stretchr/testify/assert" +) + +func TestDiscoveryAccessRequestHandler(t *testing.T) { + type testCase struct { + setup func(agentcache.Manager, Handler) + resource func() *apiv1.ResourceInstance + event proto.Event_Type + expectCached bool + expectCacheKeys []string + } + + tests := map[string]testCase{ + "wrong kind is ignored": { + resource: func() *apiv1.ResourceInstance { + return &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.EnvironmentGVK(), + }, + } + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "no status is not cached": { + resource: func() *apiv1.ResourceInstance { + ar := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ + ID: "arId", + References: []apiv1.Reference{ + { + ID: "instanceId", + Name: "instance", + Group: management.APIServiceInstanceGVK().Group, + Kind: management.APIServiceInstanceGVK().Kind, + }, + }, + }, + Name: "arName", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + } + ri, _ := ar.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "success status is cached": { + setup: func(cm agentcache.Manager, _ Handler) { + inst := &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "instanceId"}, + Name: "instance", + SubResources: map[string]interface{}{ + defs.XAgentDetails: map[string]interface{}{ + defs.AttrExternalAPIID: "api", + }, + }, + }, + } + cm.AddAPIServiceInstance(inst) + cm.AddManagedApplication(&apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "app"}, + Name: "app", + }, + }) + }, + resource: func() *apiv1.ResourceInstance { + ar := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ + ID: "arId", + References: []apiv1.Reference{ + { + ID: "instanceId", + Name: "instance", + Group: management.APIServiceInstanceGVK().Group, + Kind: management.APIServiceInstanceGVK().Kind, + }, + }, + }, + Name: "arName", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := ar.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: true, + }, + "delete event removes from cache": { + setup: func(cm agentcache.Manager, h Handler) { + inst := &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "instanceId"}, + Name: "instance", + SubResources: map[string]interface{}{ + defs.XAgentDetails: map[string]interface{}{ + defs.AttrExternalAPIID: "api", + }, + }, + }, + } + cm.AddAPIServiceInstance(inst) + cm.AddManagedApplication(&apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "app"}, + Name: "app", + }, + }) + ar := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ + ID: "arId", + References: []apiv1.Reference{ + { + ID: "instanceId", + Name: "instance", + Group: management.APIServiceInstanceGVK().Group, + Kind: management.APIServiceInstanceGVK().Kind, + }, + }, + }, + Name: "arName", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := ar.AsInstance() + h.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) + }, + resource: func() *apiv1.ResourceInstance { + ar := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ + ID: "arId", + References: []apiv1.Reference{ + { + ID: "instanceId", + Name: "instance", + Group: management.APIServiceInstanceGVK().Group, + Kind: management.APIServiceInstanceGVK().Kind, + }, + }, + }, + Name: "arName", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := ar.AsInstance() + return ri + }, + event: proto.Event_DELETED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "deleting state with success status is not cached": { + resource: func() *apiv1.ResourceInstance { + ar := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ + ID: "arId", + State: apiv1.ResourceDeleting, + }, + Name: "arName", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := ar.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + handler := NewDiscoveryAccessRequestHandler(cm) + if tc.setup != nil { + tc.setup(cm, handler) + } + ri := tc.resource() + + err := handler.Handle(NewEventContext(tc.event, nil, ri.Kind, ri.Name), nil, ri) + assert.Nil(t, err) + + if tc.expectCached { + assert.NotNil(t, cm.GetAccessRequest("arId")) + } else if tc.expectCacheKeys != nil { + assert.Equal(t, tc.expectCacheKeys, cm.GetAccessRequestCacheKeys()) + } + }) + } +} diff --git a/pkg/agent/handler/discoverymanagedapplication.go b/pkg/agent/handler/discoverymanagedapplication.go new file mode 100644 index 000000000..bec82b617 --- /dev/null +++ b/pkg/agent/handler/discoverymanagedapplication.go @@ -0,0 +1,53 @@ +package handler + +import ( + "context" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/watchmanager/proto" +) + +type discoveryManagedApplication struct { + marketplaceHandler + cache agentcache.Manager +} + +// NewDiscoveryManagedApplicationHandler creates a Handler for ManagedApplications for discovery agent cache building +func NewDiscoveryManagedApplicationHandler(cache agentcache.Manager) Handler { + return &discoveryManagedApplication{ + cache: cache, + } +} + +// Handle processes events triggered for ManagedApplications during discovery cache building +func (h *discoveryManagedApplication) Handle(ctx context.Context, _ *proto.EventMeta, resource *apiv1.ResourceInstance) error { + action := GetActionFromContext(ctx) + if resource.Kind != management.ManagedApplicationGVK().Kind { + return nil + } + + if action == proto.Event_DELETED { + return h.cache.DeleteManagedApplication(resource.Metadata.ID) + } + + app := &management.ManagedApplication{} + err := app.FromInstance(resource) + if err != nil { + return err + } + + ok := isStatusFound(app.Status) + if !ok { + return nil + } + + if h.shouldProcessForAgent(app.Status, app.Metadata.State) { + cachedApp := h.cache.GetManagedApplication(resource.Metadata.ID) + if cachedApp == nil { + h.cache.AddManagedApplication(resource) + } + } + return nil +} diff --git a/pkg/agent/handler/discoverymanagedapplication_test.go b/pkg/agent/handler/discoverymanagedapplication_test.go new file mode 100644 index 000000000..817c88835 --- /dev/null +++ b/pkg/agent/handler/discoverymanagedapplication_test.go @@ -0,0 +1,134 @@ +package handler + +import ( + "testing" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/watchmanager/proto" + "github.com/stretchr/testify/assert" +) + +func TestDiscoveryManagedApplicationHandler(t *testing.T) { + type testCase struct { + setup func(agentcache.Manager, Handler) // optional: pre-populate cache + resource func() *apiv1.ResourceInstance + event proto.Event_Type + expectCached bool + expectCacheKeys []string + } + + tests := map[string]testCase{ + "wrong kind is ignored": { + resource: func() *apiv1.ResourceInstance { + return &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.EnvironmentGVK(), + }, + } + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "no status is not cached": { + resource: func() *apiv1.ResourceInstance { + app := &management.ManagedApplication{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "appId"}, + Name: "appName", + }, + } + ri, _ := app.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "success status is cached": { + resource: func() *apiv1.ResourceInstance { + app := &management.ManagedApplication{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "appId"}, + Name: "appName", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := app.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: true, + }, + "delete event removes from cache": { + setup: func(cm agentcache.Manager, h Handler) { + app := &management.ManagedApplication{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "appId"}, + Name: "appName", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := app.AsInstance() + h.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) + }, + resource: func() *apiv1.ResourceInstance { + app := &management.ManagedApplication{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ID: "appId"}, + Name: "appName", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := app.AsInstance() + return ri + }, + event: proto.Event_DELETED, + expectCached: false, + expectCacheKeys: []string{}, + }, + "deleting state with success status is not cached": { + resource: func() *apiv1.ResourceInstance { + app := &management.ManagedApplication{ + ResourceMeta: apiv1.ResourceMeta{ + Metadata: apiv1.Metadata{ + ID: "appId", + State: apiv1.ResourceDeleting, + }, + Name: "appName", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + ri, _ := app.AsInstance() + return ri + }, + event: proto.Event_CREATED, + expectCached: false, + expectCacheKeys: []string{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + handler := NewDiscoveryManagedApplicationHandler(cm) + if tc.setup != nil { + tc.setup(cm, handler) + } + ri := tc.resource() + + err := handler.Handle(NewEventContext(tc.event, nil, ri.Kind, ri.Name), nil, ri) + assert.Nil(t, err) + + if tc.expectCached { + assert.NotNil(t, cm.GetManagedApplication("appId")) + assert.NotNil(t, cm.GetManagedApplicationByName("appName")) + } else if tc.expectCacheKeys != nil { + assert.Equal(t, tc.expectCacheKeys, cm.GetManagedApplicationCacheKeys()) + } + }) + } +} diff --git a/pkg/agent/handler/marketplacehandler.go b/pkg/agent/handler/marketplacehandler.go index 0e5e6fce8..579de1ad6 100644 --- a/pkg/agent/handler/marketplacehandler.go +++ b/pkg/agent/handler/marketplacehandler.go @@ -24,6 +24,6 @@ func (m *marketplaceHandler) shouldProcessDeleting(status *v1.ResourceStatus, st return status.Level == prov.Success.String() && state == v1.ResourceDeleting && len(finalizers) > 0 } -func (m *marketplaceHandler) shouldProcessForTrace(status *v1.ResourceStatus, state string) bool { +func (m *marketplaceHandler) shouldProcessForAgent(status *v1.ResourceStatus, state string) bool { return status.Level == prov.Success.String() && state != v1.ResourceDeleting } diff --git a/pkg/agent/handler/traceaccessrequest.go b/pkg/agent/handler/traceaccessrequest.go index 68de6c329..be85e1165 100644 --- a/pkg/agent/handler/traceaccessrequest.go +++ b/pkg/agent/handler/traceaccessrequest.go @@ -52,7 +52,7 @@ func (h *traceAccessRequestHandler) Handle(ctx context.Context, meta *proto.Even return nil } - if h.shouldProcessForTrace(ar.Status, ar.Metadata.State) { + if h.shouldProcessForAgent(ar.Status, ar.Metadata.State) { cachedAccessReq := h.cache.GetAccessRequest(resource.Metadata.ID) if cachedAccessReq == nil { h.cache.AddAccessRequest(resource) diff --git a/pkg/agent/handler/tracemanagedapplication.go b/pkg/agent/handler/tracemanagedapplication.go index 7f6e1f924..fa328ab07 100644 --- a/pkg/agent/handler/tracemanagedapplication.go +++ b/pkg/agent/handler/tracemanagedapplication.go @@ -43,7 +43,7 @@ func (h *traceManagedApplication) Handle(ctx context.Context, _ *proto.EventMeta return nil } - if h.shouldProcessForTrace(app.Status, app.Metadata.State) { + if h.shouldProcessForAgent(app.Status, app.Metadata.State) { cachedApp := h.cache.GetManagedApplication(resource.Metadata.ID) if cachedApp == nil { h.cache.AddManagedApplication(resource) diff --git a/pkg/agent/idplifecycle.go b/pkg/agent/idplifecycle.go index 7a54217ba..0948fc1f3 100644 --- a/pkg/agent/idplifecycle.go +++ b/pkg/agent/idplifecycle.go @@ -1,12 +1,32 @@ package agent import ( + "github.com/Axway/agent-sdk/pkg/apic" + apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/authz/oauth" "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util/log" ) +type idpClientAdapter struct{ client apic.Client } + +func (a *idpClientAdapter) GetAPIV1ResourceInstances(query map[string]string, url string) ([]*apiv1.ResourceInstance, error) { + return a.client.GetAPIV1ResourceInstances(query, url) +} + +func (a *idpClientAdapter) CreateOrUpdateResource(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + return a.client.CreateOrUpdateResource(ri) +} + +func (a *idpClientAdapter) CreateSubResource(rm apiv1.ResourceMeta, subs map[string]interface{}) error { + return a.client.CreateSubResource(rm, subs) +} + +func (a *idpClientAdapter) GetResource(url string) (*apiv1.ResourceInstance, error) { + return a.client.GetResource(url) +} + func manageIDPResource(idpLogger log.FieldLogger, idp config.IDPConfig) string { if !ManageIDPResourcesEnabled() { return "" @@ -63,7 +83,7 @@ func manageIDPResourceFromMetadata(idpLogger log.FieldLogger, idpCfg config.IDPC idpType = idpCfg.GetIDPType() } - idpLifecycle := oauth.NewIDPEngageLifecycle(agent.apicClient, agent.cacheManager) + idpLifecycle := oauth.NewIDPEngageLifecycle(&idpClientAdapter{agent.apicClient}, agent.cacheManager) name, err := idpLifecycle.CreateEngageResourcesFromMetadata(idpLogger, idpCfg, idpType, idpName, metadata, agent.cfg.GetAPIServerVersionURL(), getEnvCredentialPolicies(idpLogger)) if err != nil { idpLogger.WithError(err).Warn("unable to create or find IdentityProvider resource in Engage") diff --git a/pkg/agent/idplifecycle_test.go b/pkg/agent/idplifecycle_test.go index 51a1e069a..b00eb953c 100644 --- a/pkg/agent/idplifecycle_test.go +++ b/pkg/agent/idplifecycle_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/Axway/agent-sdk/pkg/apic" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/mock" @@ -19,8 +20,8 @@ import ( ) const ( - testEnvName = "test-env" - testIDPName = "test-idp" + testEnvName = "test-env" + testIDPName = "test-idp" existingIDPName = "existing-idp" ) @@ -104,7 +105,7 @@ func TestManageIDPResourceFlagDisabled(t *testing.T) { queryCallCount++ return nil, nil }, - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { inst, _ := ri.AsInstance() return inst, nil }, @@ -152,7 +153,7 @@ func TestManageIDPResourceExistingFound(t *testing.T) { GetAPIV1ResourceInstancesMock: func(_ map[string]string, _ string) ([]*apiv1.ResourceInstance, error) { return []*apiv1.ResourceInstance{existingRI}, nil }, - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCalled = true inst, _ := ri.AsInstance() return inst, nil @@ -245,7 +246,7 @@ func TestManageIDPResourceCreatedSuccessfully(t *testing.T) { GetAPIV1ResourceInstancesMock: func(_ map[string]string, _ string) ([]*apiv1.ResourceInstance, error) { return []*apiv1.ResourceInstance{}, nil }, - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCount++ inst, _ := ri.AsInstance() return inst, nil @@ -298,7 +299,7 @@ func TestManageIDPResourceCreateIDPError(t *testing.T) { GetAPIV1ResourceInstancesMock: func(_ map[string]string, _ string) ([]*apiv1.ResourceInstance, error) { return []*apiv1.ResourceInstance{}, nil }, - CreateOrUpdateResourceMock: func(_ apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(_ apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCount++ return nil, errors.New("server error") }, @@ -342,7 +343,7 @@ func TestManageIDPResourceProviderNotFound(t *testing.T) { // Bare agent with an empty registry — no RegisterProvider call. resetResources() agent.apicClient = &mock.Client{ - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCount++ inst, _ := ri.AsInstance() return inst, nil @@ -380,7 +381,7 @@ func TestManageIDPResourceMetadataWriteError(t *testing.T) { GetAPIV1ResourceInstancesMock: func(_ map[string]string, _ string) ([]*apiv1.ResourceInstance, error) { return []*apiv1.ResourceInstance{}, nil }, - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCount++ if createCount == 1 { inst, _ := ri.AsInstance() @@ -508,7 +509,7 @@ func TestManageIDPResource(t *testing.T) { queryCount++ return []*apiv1.ResourceInstance{}, nil }, - CreateOrUpdateResourceMock: func(ri apiv1.Interface) (*apiv1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(ri apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { createCalled = true if tc.createErr != nil { return nil, tc.createErr diff --git a/pkg/agent/poller/client.go b/pkg/agent/poller/client.go index d23410238..470cb3717 100644 --- a/pkg/agent/poller/client.go +++ b/pkg/agent/poller/client.go @@ -24,11 +24,16 @@ type PollClient struct { newListener events.NewListenerFunc onClientStop onClientStopCb onStreamConnection func() + onReconnect func() error poller *pollExecutor newPollManager newPollExecutorFunc harvesterConfig harvesterConfig mutex sync.RWMutex initialized bool + firstStart bool + connectedCh chan struct{} // closed when the first connection is live in Start() + connectedOnce sync.Once // ensures connectedCh is closed at most once across reconnects + startErrCh chan error // buffered(1): receives error if Start() fails before connecting } type harvesterConfig struct { @@ -54,6 +59,9 @@ func NewPollClient( newListener: events.NewEventListener, newPollManager: newPollExecutor, poller: nil, + firstStart: true, + connectedCh: make(chan struct{}), + startErrCh: make(chan error, 1), } for _, opt := range options { @@ -67,6 +75,13 @@ func NewPollClient( func (p *PollClient) Start() error { ctx, cancel := context.WithCancelCause(context.Background()) defer cancel(nil) + if p.onReconnect != nil && !p.firstStart { + err := p.onReconnect() + if err != nil { + return err + } + } + eventCh := make(chan *proto.Event) p.mutex.Lock() @@ -78,9 +93,27 @@ func (p *PollClient) Start() error { p.listener.Listen() p.poller.RegisterWatch(eventCh) + // pollExecutor may cancel ctx internally (e.g. harvester not configured). + select { + case <-ctx.Done(): + err := context.Cause(ctx) + if err == nil { + err = fmt.Errorf("poll client context cancelled during setup") + } + select { + case p.startErrCh <- err: + default: + } + return err + default: + } + if p.onStreamConnection != nil { p.onStreamConnection() } + p.connectedOnce.Do(func() { close(p.connectedCh) }) + + p.firstStart = false p.mutex.Lock() p.initialized = true @@ -106,10 +139,29 @@ func (p *PollClient) Status() error { return nil } +// WaitForReady blocks until Start() has established a connection to Central, +// or until ctx is cancelled, or until Start() exits with an error before connecting. +func (p *PollClient) WaitForReady(ctx context.Context) error { + select { + case <-p.connectedCh: + return nil + case err := <-p.startErrCh: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + // Stop stops the streamer func (p *PollClient) Stop() { - p.poller.Stop() - p.listener.Stop() + p.mutex.RLock() + defer p.mutex.RUnlock() + if p.poller != nil { + p.poller.Stop() + } + if p.listener != nil { + p.listener.Stop() + } } // Healthcheck returns a healthcheck diff --git a/pkg/agent/poller/client_test.go b/pkg/agent/poller/client_test.go index 580930960..293111086 100644 --- a/pkg/agent/poller/client_test.go +++ b/pkg/agent/poller/client_test.go @@ -2,6 +2,7 @@ package poller import ( "context" + "fmt" "testing" "time" @@ -10,6 +11,7 @@ import ( apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/harvester" hc "github.com/Axway/agent-sdk/pkg/util/healthcheck" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" "github.com/stretchr/testify/assert" @@ -136,6 +138,86 @@ func (m mockHarvester) ReceiveSyncEvents(_ context.Context, _ string, _ int64, _ return 0, m.err } +func TestPollClientWaitForReady(t *testing.T) { + catchUpErr := fmt.Errorf("harvester catch-up failed") + blockCh := make(chan struct{}) + + cases := map[string]struct { + harvester harvester.Harvest + ctxTimeout time.Duration + expectedErr error + cleanup func() + }{ + "connects successfully": { + harvester: &mockHarvester{}, + ctxTimeout: 2 * time.Second, + expectedErr: nil, + }, + "context deadline exceeded before connection": { + harvester: &blockingHarvester{block: blockCh}, + ctxTimeout: 50 * time.Millisecond, + expectedErr: context.DeadlineExceeded, + cleanup: func() { close(blockCh) }, + }, + "start fails before connecting": { + harvester: &blockingHarvester{err: catchUpErr}, // nil block: EventCatchUp returns err immediately + ctxTimeout: 2 * time.Second, + expectedErr: catchUpErr, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + wt := management.NewWatchTopic("mocktopic") + ri, _ := wt.AsInstance() + httpClient := &mockAPIClient{ri: ri} + + cacheManager := agentcache.NewAgentCacheManager(cfg, false) + seq := events.NewSequenceProvider(cacheManager, wt.Name) + seq.SetSequence(1) + + pc, err := NewPollClient(httpClient, cfg, nil, WithHarvester(tc.harvester, seq, wt.GetSelfLink())) + assert.Nil(t, err) + + pc.newPollManager = func(interval time.Duration, options ...executorOpt) *pollExecutor { + p := newPollExecutor(cfg.PollInterval, options...) + p.harvester = tc.harvester + return p + } + + go func() { _ = pc.Start() }() + defer pc.Stop() + if tc.cleanup != nil { + defer tc.cleanup() + } + + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + + err = pc.WaitForReady(ctx) + assert.ErrorIs(t, err, tc.expectedErr) + }) + } +} + +// blockingHarvester blocks EventCatchUp until block is closed (or returns err if block is nil). +type blockingHarvester struct { + block chan struct{} + err error +} + +func (m *blockingHarvester) EventCatchUp(_ context.Context, _ string, _ chan *proto.Event) error { + if m.block == nil { + return m.err + } + <-m.block + return m.err +} + +func (m *blockingHarvester) ReceiveSyncEvents(_ context.Context, _ string, _ int64, _ chan *proto.Event) (int64, error) { + return 0, nil +} + var watchTopic = &management.WatchTopic{ ResourceMeta: apiv1.ResourceMeta{}, Owner: nil, diff --git a/pkg/agent/poller/options.go b/pkg/agent/poller/options.go index 07ecdb3c3..41f987d67 100644 --- a/pkg/agent/poller/options.go +++ b/pkg/agent/poller/options.go @@ -37,6 +37,14 @@ func WithOnConnect() ClientOpt { } } +// WithOnReconnect sets a callback to execute when a connection to central is restored. +// This is used to validate the persisted cache after reconnection. +func WithOnReconnect(cb func() error) ClientOpt { + return func(pc *PollClient) { + pc.onReconnect = cb + } +} + type executorOpt func(m *pollExecutor) func withHarvester(cfg harvesterConfig) executorOpt { diff --git a/pkg/agent/provisioning_test.go b/pkg/agent/provisioning_test.go index 7b157a29e..e19f049d3 100644 --- a/pkg/agent/provisioning_test.go +++ b/pkg/agent/provisioning_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Axway/agent-sdk/pkg/apic" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/mock" @@ -26,7 +27,7 @@ func TestNewCredentialRequestBuilder(t *testing.T) { InitializeWithAgentFeatures(cfg, &config.AgentFeaturesConfiguration{}, nil) agent.apicClient = &mock.Client{ - CreateOrUpdateResourceMock: func(data v1.Interface) (*v1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(data v1.Interface, _ ...apic.UpdateOption) (*v1.ResourceInstance, error) { ri, _ := data.AsInstance() return ri, nil }, @@ -110,7 +111,7 @@ func TestNewAccessRequestBuilder(t *testing.T) { InitializeWithAgentFeatures(cfg, &config.AgentFeaturesConfiguration{}, postCfgFunc) agent.apicClient = &mock.Client{ - CreateOrUpdateResourceMock: func(data v1.Interface) (*v1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(data v1.Interface, _ ...apic.UpdateOption) (*v1.ResourceInstance, error) { ri, _ := data.AsInstance() return ri, nil }, @@ -169,7 +170,7 @@ func TestNewApplicationProfileDefinitionBuilder(t *testing.T) { var createOrUpdateCalled bool agent.apicClient = &mock.Client{ - CreateOrUpdateResourceMock: func(data v1.Interface) (*v1.ResourceInstance, error) { + CreateOrUpdateResourceMock: func(data v1.Interface, _ ...apic.UpdateOption) (*v1.ResourceInstance, error) { sentAPD, _ = data.AsInstance() createOrUpdateCalled = true return sentAPD, nil diff --git a/pkg/agent/resource/manager.go b/pkg/agent/resource/manager.go index 0ab2321c1..293f185f8 100644 --- a/pkg/agent/resource/manager.go +++ b/pkg/agent/resource/manager.go @@ -23,7 +23,8 @@ import ( const qaTriggerSevenDayRefreshCache = "QA_CENTRAL_TRIGGER_REFRESH_CACHE" type EventSyncCache interface { - RebuildCache() + RebuildCache(filters ...management.WatchTopicSpecFilters) error + ValidateCache() ([]management.WatchTopicSpecFilters, error) } // Manager - interface to manage agent resource @@ -164,9 +165,8 @@ func (a *agentResourceManager) UpdateAgentStatus(status, prevStatus, message str agentStatus := newDAStatus(agentInstance.ResourceMeta, status, prevStatus, message) // See if we need to rebuildCache - timeToRebuild, _ := a.shouldRebuildCache() - if timeToRebuild && a.rebuildCache != nil { - a.rebuildCache.RebuildCache() + if needsRebuild, filters := a.shouldRebuildCache(); needsRebuild && a.rebuildCache != nil { + a.rebuildCache.RebuildCache(filters...) } subResources := make(map[string]interface{}) @@ -181,50 +181,65 @@ func (a *agentResourceManager) UpdateAgentStatus(status, prevStatus, message str return err } -// 1. On UpdateAgentStatus, if x-agent-details, key "cacheUpdateTime" doesn't exist or empty, rebuild cache to populate cacheUpdateTime -// 2. On UpdateAgentStatus, if x-agent-details exists, check to see if its past 7 days since rebuildCache was ran. If its pass 7 days, rebuildCache -func (a *agentResourceManager) shouldRebuildCache() (bool, error) { +// 1. On UpdateAgentStatus, if x-agent-details, key "cacheUpdateTime" doesn't exist or empty, rebuild cache to populate cacheUpdateTime +// 2. On UpdateAgentStatus, if x-agent-details exists, check to see if its past 7 days since rebuildCache was ran. +// If past 7 days, validate the cache first and only rebuild if out of sync. +func (a *agentResourceManager) shouldRebuildCache() (bool, []management.WatchTopicSpecFilters) { // Only perform periodic/self-healing cache rebuild for non-gRPC agents if a.cfg != nil && a.cfg.IsUsingGRPC() { return false, nil } - rebuildCache := false agentInstance := a.GetAgentResource() agentDetails := agentInstance.GetSubResource(definitions.XAgentDetails) if agentDetails == nil { // x-agent-details hasn't been established yet. Rebuild cache to populate cacheUpdateTime a.logger.Trace("create x-agent-detail subresource and add key 'cacheUpdateTime'") - rebuildCache = true - } else { - value, exists := agentDetails.(map[string]interface{})["cacheUpdateTime"] - if value != nil { - logger := a.logger.WithField("cacheUpdateTime", value) - // get current cacheUpdateTime from x-agent-details - convToTimestamp, err := strconv.ParseInt(value.(string), 10, 64) - if err != nil { - logger.WithError(err).Error("unable to parse cache update time") - return false, err - } - currentCacheUpdateTime := time.Unix(0, convToTimestamp) - logger.Trace("the current scheduled refresh cache date - %s", time.Unix(0, currentCacheUpdateTime.UnixNano()).Format("2006-01-02 15:04:05.000000")) + return true, nil + } + + value, exists := agentDetails.(map[string]interface{})["cacheUpdateTime"] + if value == nil { + if !exists { + // x-agent-details exists, however, cacheUpdateTime key doesn't exist. Rebuild cache to populate cacheUpdateTime + a.logger.Trace("update x-agent-detail subresource and add key 'cacheUpdateTime'") + return true, nil + } + return false, nil + } - // check to see if 7 days have passed since last refresh cache. currentCacheUpdateTime is the date at the time we rebuilt cache plus 7 days(in event sync - RebuildCache) - if a.getCurrentTime() > currentCacheUpdateTime.UnixNano() { - logger.Trace("the current date is greater than the current scheduled refresh date - time to rebuild cache") - rebuildCache = true + logger := a.logger.WithField("cacheUpdateTime", value) + strVal, ok := value.(string) + if !ok { + logger.Warn("cacheUpdateTime is not a string, triggering rebuild") + return true, nil + } + + convToTimestamp, err := strconv.ParseInt(strVal, 10, 64) + if err != nil { + logger.WithError(err).Error("unable to parse cache update time") + return true, nil + } + + currentCacheUpdateTime := time.Unix(0, convToTimestamp) + logger.Tracef("the current scheduled refresh cache date - %s", time.Unix(0, currentCacheUpdateTime.UnixNano()).Format("2006-01-02 15:04:05.000000")) + + // check to see if 7 days have passed since last refresh cache + if a.getCurrentTime() > currentCacheUpdateTime.UnixNano() { + logger.Tracef("the current date is greater than the current scheduled refresh date - validating cache before deciding to rebuild") + if a.rebuildCache != nil { + failedFilters, err := a.rebuildCache.ValidateCache() + if err != nil || len(failedFilters) > 0 { + logger.WithError(err).Trace("cache validation failed - time to rebuild cache") + return true, failedFilters } } else { - if !exists { - // x-agent-details exists, however, cacheUpdateTime key doesn't exist. Rebuild cache to populate cacheUpdateTime - a.logger.Trace("update x-agent-detail subresource and add key 'cacheUpdateTime'") - rebuildCache = true - } + return true, nil } } - return rebuildCache, nil + return false, nil } func (a *agentResourceManager) getCurrentTime() int64 { diff --git a/pkg/agent/resource/manager_test.go b/pkg/agent/resource/manager_test.go index 2a69f0d24..da3287765 100644 --- a/pkg/agent/resource/manager_test.go +++ b/pkg/agent/resource/manager_test.go @@ -1,11 +1,16 @@ package resource import ( + "fmt" + "strconv" "testing" + "time" "github.com/Axway/agent-sdk/pkg/api" + "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/apic/mock" "github.com/Axway/agent-sdk/pkg/util/errors" + "github.com/Axway/agent-sdk/pkg/util/log" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" @@ -290,3 +295,112 @@ func assertAgentStatusResource(t *testing.T, agentRes *v1.ResourceInstance, agen assert.Equal(t, previousState, statusSubRes.PreviousState) assert.Equal(t, message, statusSubRes.Message) } + +type mockEventSyncCache struct { + rebuildCalled int + rebuildFilters []management.WatchTopicSpecFilters + validateCalled int + validateFilters []management.WatchTopicSpecFilters + validateErr error +} + +func (m *mockEventSyncCache) RebuildCache(filters ...management.WatchTopicSpecFilters) error { + m.rebuildCalled++ + m.rebuildFilters = filters + return nil +} + +func (m *mockEventSyncCache) ValidateCache() ([]management.WatchTopicSpecFilters, error) { + m.validateCalled++ + return m.validateFilters, m.validateErr +} + +func TestShouldRebuildCache(t *testing.T) { + pastDeadline := strconv.FormatInt(time.Now().Add(-7*24*time.Hour-time.Second).UnixNano(), 10) + futureDeadline := strconv.FormatInt(time.Now().Add(7*24*time.Hour).UnixNano(), 10) + + apiSvcFilter := management.WatchTopicSpecFilters{ + Group: management.APIServiceGVK().Group, + Kind: management.APIServiceGVK().Kind, + } + + tests := []struct { + name string + agentDetails interface{} + validateFilters []management.WatchTopicSpecFilters + validateErr error + expectedRebuild bool + expectedFilters []management.WatchTopicSpecFilters + expectedValidate bool + }{ + { + name: "no x-agent-details - rebuild unconditionally", + agentDetails: nil, + expectedRebuild: true, + }, + { + name: "x-agent-details without cacheUpdateTime - rebuild unconditionally", + agentDetails: map[string]interface{}{}, + expectedRebuild: true, + }, + { + name: "deadline not yet reached - no rebuild", + agentDetails: map[string]interface{}{"cacheUpdateTime": futureDeadline}, + expectedRebuild: false, + }, + { + name: "7 days passed, cache valid - no rebuild, timer reset", + agentDetails: map[string]interface{}{"cacheUpdateTime": pastDeadline}, + validateErr: nil, + expectedRebuild: false, + expectedValidate: true, + }, + { + name: "7 days passed, cache invalid - rebuild with failed filters", + agentDetails: map[string]interface{}{"cacheUpdateTime": pastDeadline}, + validateFilters: []management.WatchTopicSpecFilters{apiSvcFilter}, + validateErr: fmt.Errorf("cache out of sync"), + expectedRebuild: true, + expectedFilters: []management.WatchTopicSpecFilters{apiSvcFilter}, + expectedValidate: true, + }, + { + name: "unparseable cacheUpdateTime - rebuild unconditionally", + agentDetails: map[string]interface{}{"cacheUpdateTime": "not-a-timestamp"}, + expectedRebuild: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockCache := &mockEventSyncCache{validateFilters: tc.validateFilters, validateErr: tc.validateErr} + + resource := createDiscoveryAgentRes("111", "Test-DA", "test-dataplane", "") + if tc.agentDetails != nil { + resource.SubResources = map[string]interface{}{ + definitions.XAgentDetails: tc.agentDetails, + } + } + + cfg := &config.CentralConfiguration{} + cfg.AgentName = "Test-DA" + cfg.AgentType = config.DiscoveryAgent + + m := &agentResourceManager{ + cfg: cfg, + agentResource: resource, + rebuildCache: mockCache, + logger: log.NewFieldLogger(), + } + + shouldRebuild, filters := m.shouldRebuildCache() + assert.Equal(t, tc.expectedRebuild, shouldRebuild) + assert.Equal(t, tc.expectedFilters, filters) + if tc.expectedValidate { + assert.Equal(t, 1, mockCache.validateCalled) + } else { + assert.Equal(t, 0, mockCache.validateCalled) + } + }) + } +} diff --git a/pkg/agent/stream/client.go b/pkg/agent/stream/client.go index b9ca3c327..c8d0758c7 100644 --- a/pkg/agent/stream/client.go +++ b/pkg/agent/stream/client.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "strconv" + "sync" "sync/atomic" "github.com/Axway/agent-sdk/pkg/agent/events" @@ -35,13 +36,14 @@ import ( type StreamerClient struct { apiClient events.APIClient handlers []handler.Handler - listener *events.EventListener + listener atomic.Pointer[events.EventListener] manager wm.Manager newListener events.NewListenerFunc newManager wm.NewManagerFunc requestQueue events.RequestQueue newRequestQueue events.NewRequestQueueFunc onStreamConnection func() + onReconnect func() error sequence events.SequenceProvider topicSelfLink string watchCfg *wm.Config @@ -54,7 +56,12 @@ type StreamerClient struct { onEventSyncError func() isInitialized atomic.Bool isRunning atomic.Bool + firstStart bool + cancelMu sync.Mutex cancel context.CancelCauseFunc + connectedCh chan struct{} // closed when the first connection is live in Start() + connectedOnce sync.Once // ensures connectedCh is closed at most once across reconnects + startErrCh chan error // buffered(1): receives error if Start() fails before connecting } // NewStreamerClient creates a StreamerClient @@ -88,6 +95,9 @@ func NewStreamerClient( newRequestQueue: events.NewRequestQueue, logger: logger, environmentURL: cfg.GetEnvironmentURL(), + firstStart: true, + connectedCh: make(chan struct{}), + startErrCh: make(chan error, 1), } s.isInitialized.Store(false) @@ -148,12 +158,22 @@ func (s *StreamerClient) Start() error { defer s.isRunning.Store(false) ctx, cancel := context.WithCancelCause(context.Background()) + s.cancelMu.Lock() s.cancel = cancel - defer cancel(nil) + s.cancelMu.Unlock() + defer cancel(nil) // local variable: safe to call without lock in the same goroutine + + if s.onReconnect != nil && !s.firstStart { + err := s.onReconnect() + if err != nil { + return err + } + } eventCh, requestCh := make(chan *proto.Event), make(chan *proto.Request, 1) - s.listener = s.newListener(ctx, cancel, eventCh, s.apiClient, s.sequence, s.handlers...) - defer s.listener.Stop() + l := s.newListener(ctx, cancel, eventCh, s.apiClient, s.sequence, s.handlers...) + s.listener.Store(l) + defer l.Stop() s.requestQueue = s.newRequestQueue(ctx, cancel, requestCh) defer s.requestQueue.Stop() @@ -161,16 +181,23 @@ func (s *StreamerClient) Start() error { wmOptions := append(s.watchOpts, wm.WithRequestChannel(requestCh), wm.WithContext(ctx, cancel)) manager, err := s.newManager(s.watchCfg, wmOptions...) if err != nil { + select { + case s.startErrCh <- err: + default: + } return err } defer manager.CloseConn() s.manager = manager - s.listener.Listen() + l.Listen() _, err = s.manager.RegisterWatch(s.topicSelfLink, eventCh) if s.onStreamConnection != nil { s.onStreamConnection() } + s.connectedOnce.Do(func() { close(s.connectedCh) }) + + s.firstStart = false if err != nil { return err @@ -192,7 +219,7 @@ func (s *StreamerClient) Status() error { return fmt.Errorf("stream client is not yet initialized") } - if s.manager == nil || s.listener == nil || s.requestQueue == nil { + if s.manager == nil || s.listener.Load() == nil || s.requestQueue == nil { return fmt.Errorf("stream client is not ready") } if ok := s.manager.Status(); !ok { @@ -204,11 +231,27 @@ func (s *StreamerClient) Status() error { // Stop stops the StreamerClient func (s *StreamerClient) Stop() { + s.cancelMu.Lock() + defer s.cancelMu.Unlock() if s.cancel != nil { s.cancel(nil) } } +// WaitForReady blocks until Start() has established a connection to Central, +// or until ctx is cancelled, or until Start() exits with an error before connecting. +func (s *StreamerClient) WaitForReady(ctx context.Context) error { + select { + case <-s.connectedCh: + return nil + case err := <-s.startErrCh: + s.logger.WithError(err).Error("stream client failed to connect") + return err + case <-ctx.Done(): + return ctx.Err() + } +} + // Healthcheck - health check for stream client func (s *StreamerClient) Healthcheck(_ string) *hc.Status { if err := s.Status(); err != nil { diff --git a/pkg/agent/stream/client_test.go b/pkg/agent/stream/client_test.go index 519a9aefb..f140cf34f 100644 --- a/pkg/agent/stream/client_test.go +++ b/pkg/agent/stream/client_test.go @@ -2,6 +2,7 @@ package stream import ( "context" + "fmt" "testing" "time" @@ -73,16 +74,17 @@ func TestNewStreamer(t *testing.T) { }() <-manager.readyCh - assert.Equal(t, hc.OK, hc.RunChecks()) + // wait for isInitialized to be set after requestQueue.Start() before checking health check + assert.Eventually(t, func() bool { return hc.RunChecks() == hc.OK }, time.Second, 10*time.Millisecond) // should stop the listener and write nil to the listener's error channel - streamer.listener.Stop() + streamer.listener.Load().Stop() err = <-errCh assert.NotNil(t, err) assert.Equal(t, hc.FAIL, hc.RunChecks()) streamer.manager = nil - streamer.listener = nil + streamer.listener.Store(nil) go func() { err := streamer.Start() @@ -100,6 +102,55 @@ func TestNewStreamer(t *testing.T) { assert.Equal(t, hc.FAIL, hc.RunChecks()) } +func TestStreamerWaitForReady(t *testing.T) { + managerErr := fmt.Errorf("connection refused") + + cases := map[string]struct { + newManager func(*wm.Config, ...wm.Option) (wm.Manager, error) + ctxTimeout time.Duration + expectedErr error + }{ + "connects successfully": { + newManager: func(cfg *wm.Config, opts ...wm.Option) (wm.Manager, error) { + return &mockManager{status: true}, nil // nil readyCh: RegisterWatch returns immediately + }, + ctxTimeout: 2 * time.Second, + expectedErr: nil, + }, + "context deadline exceeded before connection": { + newManager: func(cfg *wm.Config, opts ...wm.Option) (wm.Manager, error) { + return &blockingManager{block: make(chan struct{})}, nil + }, + ctxTimeout: 50 * time.Millisecond, + expectedErr: context.DeadlineExceeded, + }, + "start fails before connecting": { + newManager: func(cfg *wm.Config, opts ...wm.Option) (wm.Manager, error) { + return nil, managerErr + }, + ctxTimeout: 2 * time.Second, + expectedErr: managerErr, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + streamer, err := NewStreamerClient(&mockAPIClient{}, NewConfig(), &mockTokenGetter{}, nil) + assert.Nil(t, err) + streamer.newManager = tc.newManager + + go func() { _ = streamer.Start() }() + defer streamer.Stop() + + ctx, cancel := context.WithTimeout(context.Background(), tc.ctxTimeout) + defer cancel() + + err = streamer.WaitForReady(ctx) + assert.ErrorIs(t, err, tc.expectedErr) + }) + } +} + func TestClientOptions(t *testing.T) { sequence := &mockSequence{} sequence.SetSequence(1) @@ -210,10 +261,11 @@ func TestStatusUpdates(t *testing.T) { } } + func stop(t *testing.T, streamer *StreamerClient, errCh chan error) { t.Helper() // should stop the listener and write nil to the listener's error channel - streamer.listener.Stop() + streamer.listener.Load().Stop() err := <-errCh assert.NotNil(t, err) @@ -242,6 +294,20 @@ func (m *mockManager) Status() bool { return m.status } +// blockingManager blocks RegisterWatch until block is closed, simulating a slow/unresponsive Central. +type blockingManager struct { + block chan struct{} +} + +func (m *blockingManager) RegisterWatch(_ string, _ chan *proto.Event) (string, error) { + <-m.block + return "", nil +} + +func (m *blockingManager) CloseWatch(_ string) error { return nil } +func (m *blockingManager) CloseConn() {} +func (m *blockingManager) Status() bool { return true } + type mockAPIClient struct { resource *apiv1.ResourceInstance getErr error diff --git a/pkg/agent/stream/options.go b/pkg/agent/stream/options.go index 012992b05..38c2920c8 100644 --- a/pkg/agent/stream/options.go +++ b/pkg/agent/stream/options.go @@ -57,3 +57,11 @@ func WithOnStreamConnection() StreamerOpt { } } } + +// WithOnReconnect sets a callback to execute when a connection to central is restored. +// This is used to validate the persisted cache after reconnection. +func WithOnReconnect(cb func() error) StreamerOpt { + return func(client *StreamerClient) { + client.onReconnect = cb + } +} diff --git a/pkg/apic/apiservice.go b/pkg/apic/apiservice.go index 1c413dcd8..5d1072f21 100644 --- a/pkg/apic/apiservice.go +++ b/pkg/apic/apiservice.go @@ -188,8 +188,6 @@ func buildAPIServiceStatusSubResource(status *apiv1.ResourceStatus, ownerErr err // processService - func (c *ServiceClient) processService(serviceBody *ServiceBody) (*management.APIService, error) { // Default action to create service - serviceURL := c.cfg.GetServicesURL() - httpMethod := http.MethodPost serviceBody.serviceContext.serviceAction = addAPI serviceBody.specHashes = map[string]string{} @@ -199,6 +197,7 @@ func (c *ServiceClient) processService(serviceBody *ServiceBody) (*management.AP return nil, err } + var existingRI *apiv1.ResourceInstance if svc != nil && serviceBody.IsRevisionOnly() { serviceBody.serviceContext.serviceAction = updateAPI serviceBody.serviceContext.serviceName = svc.Name @@ -206,20 +205,18 @@ func (c *ServiceClient) processService(serviceBody *ServiceBody) (*management.AP c.setSpecHashesOnServiceBody(serviceBody, svc) return svc, nil } else if svc != nil { + existingRI, _ = svc.AsInstance() serviceBody.serviceContext.serviceAction = updateAPI - httpMethod = http.MethodPut - serviceURL += "/" + svc.Name c.updateAPIService(serviceBody, svc) } else { svc = c.buildAPIService(serviceBody) } - - buffer, err := json.Marshal(svc) - if err != nil { - return nil, err - } - - ri, err := c.apiServiceDeployAPI(httpMethod, serviceURL, buffer) + addSpecHashToResource(svc) + ri, err := c.CreateOrUpdateResource(svc, + WithExistingResourceInstance(existingRI), + WithSkipSetSpecHash(true), + WithSkipXAgentDetailUpdate(true), + ) if err != nil { return nil, err } @@ -249,12 +246,6 @@ func (c *ServiceClient) updateAPIServiceSubresources(svc *management.APIService, subResources[management.ApiServiceStatusSubResourceName] = svc.Status } - if len(svc.SubResources) > 0 { - if xAgentDetail, ok := svc.SubResources[defs.XAgentDetails]; ok { - subResources[defs.XAgentDetails] = xAgentDetail - } - } - if updateSource && svc.Source != nil { subResources[management.ApiServiceSourceSubResourceName] = svc.Source } diff --git a/pkg/apic/apiservice_test.go b/pkg/apic/apiservice_test.go index 380d2935e..09c4db9e7 100644 --- a/pkg/apic/apiservice_test.go +++ b/pkg/apic/apiservice_test.go @@ -199,13 +199,13 @@ func TestPublishServiceRevisionOnly(t *testing.T) { responses: []api.MockResponse{ {FileName: testAPIServiceFile, RespCode: http.StatusCreated}, // POST service {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service source subresource - {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service status subresource {FileName: testRevisionFile, RespCode: http.StatusCreated}, // POST revision {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // revision subresource {FileName: testInstanceFile, RespCode: http.StatusCreated}, // POST instance {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // instance subresource {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // spec hashes update - {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service source update + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource }, }, "new service: revision-only creates service and revision, skips instance": { @@ -213,10 +213,11 @@ func TestPublishServiceRevisionOnly(t *testing.T) { responses: []api.MockResponse{ {FileName: testAPIServiceFile, RespCode: http.StatusCreated}, // POST service {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service source subresource - {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service status subresource {FileName: testRevisionFile, RespCode: http.StatusCreated}, // POST revision {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // revision subresource {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // spec hashes update + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource }, }, "existing service: full publish updates service, revision, and instance": { @@ -224,8 +225,8 @@ func TestPublishServiceRevisionOnly(t *testing.T) { existingSvc: createAPIService(serviceBody.APIName, serviceBody.RestAPIID, "", "", false), responses: []api.MockResponse{ {FileName: testAPIServiceFile, RespCode: http.StatusOK}, // PUT service + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service status source subresource {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource - {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service source subresource {FileName: testRevisionListFile, RespCode: http.StatusOK}, // GET revision list (updateAPI path) {FileName: testRevisionFile, RespCode: http.StatusOK}, // GET revision count {FileName: testRevisionFile, RespCode: http.StatusOK}, // GET revision by name @@ -233,7 +234,7 @@ func TestPublishServiceRevisionOnly(t *testing.T) { {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // revision x-agent-details subresource {FileName: testInstanceFile, RespCode: http.StatusCreated}, // POST instance {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // instance x-agent-details subresource - {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // spec hashes update + {FileName: testAgentDetailsFile, RespCode: http.StatusOK}, // service x-agent-details subresource }, }, "existing service: revision-only skips service update and instance, creates revision only": { @@ -344,31 +345,27 @@ func TestUpdateService(t *testing.T) { // tests for updating existing revision httpClient.SetResponses([]api.MockResponse{ { - FileName: testAPIServiceFile, // for call to update the service + FileName: testAPIServiceFile, // for call to create the service RespCode: http.StatusOK, }, { - FileName: testAPIServiceFile, // for call to update the service subresource + FileName: testRevisionFile, // for call to create the serviceRevision RespCode: http.StatusOK, }, { - FileName: testRevisionFile, // for call to update the service subresource + FileName: testRevisionFile, // for call to update the serviceRevision x-agent-details subresource RespCode: http.StatusOK, }, { - FileName: testRevisionFile, // for call to update the serviceRevision - RespCode: http.StatusOK, - }, - { - FileName: testInstanceFile, // for call to update the serviceRevision subresource + FileName: testInstanceFile, // for call to create the serviceInstance RespCode: http.StatusOK, }, { - FileName: testInstanceFile, // for call to update the serviceRevision subresource + FileName: testInstanceFile, // for call to update the serviceInstance x-agent-details subresource RespCode: http.StatusOK, }, { - FileName: testAPIServiceFile, // for call to update the service subresource + FileName: testAgentDetailsFile, // for call to update the service x-agent-details subresource with revision spec hash RespCode: http.StatusOK, }, }) @@ -387,19 +384,7 @@ func TestUpdateService(t *testing.T) { // tests for updating existing instance with same endpoint httpClient.SetResponses([]api.MockResponse{ { - FileName: testAPIServiceFile, // for call to update the service - RespCode: http.StatusOK, - }, - { - FileName: testRevisionListFile, // for call to update the service subresource - RespCode: http.StatusOK, - }, - { - FileName: testRevisionFile, // for call to get the serviceRevision count - RespCode: http.StatusOK, - }, - { - FileName: testRevisionFile, // for call to get the serviceRevision count based on name + FileName: testRevisionListFile, // for call to get the serviceRevision count based on name RespCode: http.StatusOK, }, { @@ -407,11 +392,11 @@ func TestUpdateService(t *testing.T) { RespCode: http.StatusOK, }, { - FileName: testRevisionFile, // for call to update the serviceRevision subresource + FileName: testRevisionFile, // for call to update the serviceRevision x-agent-details subresource RespCode: http.StatusOK, }, { - FileName: testInstanceFile, // for call to update the serviceinstance + FileName: testInstanceFile, // for call to get the serviceinstance RespCode: http.StatusOK, }, { @@ -419,7 +404,7 @@ func TestUpdateService(t *testing.T) { RespCode: http.StatusOK, }, { - FileName: testInstanceFile, // for call to update the serviceinstance subresource + FileName: testInstanceFile, // for call to update the serviceinstance x-agent-details subresource RespCode: http.StatusOK, }, }) @@ -832,7 +817,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusCreated, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -851,7 +836,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusCreated, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -871,7 +856,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusCreated, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -891,7 +876,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusOK, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -911,7 +896,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusOK, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -932,7 +917,7 @@ func TestServiceSourceUpdates(t *testing.T) { RespCode: http.StatusOK, }, { - FileName: testAPIServiceFile, // call to update x-agent-details subresource + FileName: testAPIServiceFile, // call to update status source subresource RespCode: http.StatusOK, }, { @@ -952,11 +937,6 @@ func TestServiceSourceUpdates(t *testing.T) { FileName: testAPIServiceFile, // call to update the service RespCode: http.StatusOK, }, - { - FileName: testAPIServiceFile, // call to update x-agent-details subresource - RespCode: http.StatusOK, - }, - // no source subresource update }, }, } diff --git a/pkg/apic/apiserviceinstance.go b/pkg/apic/apiserviceinstance.go index 7ed5c5c02..9bf1912be 100644 --- a/pkg/apic/apiserviceinstance.go +++ b/pkg/apic/apiserviceinstance.go @@ -202,8 +202,6 @@ func (c *ServiceClient) processInstance(serviceBody *ServiceBody) error { } } - addSpecHashToResource(instance) - ri, err := c.CreateOrUpdateResource(instance) if err != nil { if serviceBody.serviceContext.serviceAction == addAPI { diff --git a/pkg/apic/client.go b/pkg/apic/client.go index bf8ad0f8d..8ebc9bb86 100644 --- a/pkg/apic/client.go +++ b/pkg/apic/client.go @@ -62,6 +62,14 @@ const ( // ValidPolicies - list of valid auth policies supported by Central. Add to this list as more policies are supported. var ValidPolicies = []string{Apikey, Passthrough, Oauth, Basic} +type UpdateOption func(*updateOptions) + +type updateOptions struct { + existingRI *apiv1.ResourceInstance + skipSetSpecHash bool + skipXAgentDetailUpdate bool +} + // Client - interface type Client interface { SetTokenGetter(tokenRequester auth.PlatformTokenGetter) @@ -78,6 +86,7 @@ type Client interface { GetAPIServiceInstances(query map[string]string, URL string) ([]*management.APIServiceInstance, error) GetAPIV1ResourceInstances(query map[string]string, URL string) ([]*apiv1.ResourceInstance, error) GetAPIV1ResourceInstancesWithPageSize(query map[string]string, URL string, pageSize int) ([]*apiv1.ResourceInstance, error) + GetAPIV1ResourceCount(URL string) (int, error) GetAPIServiceByName(name string) (*management.APIService, error) GetAPIServiceInstanceByName(name string) (*management.APIServiceInstance, error) GetAPIRevisionByName(name string) (*management.APIServiceRevision, error) @@ -93,7 +102,7 @@ type Client interface { UpdateResourceFinalizer(ri *apiv1.ResourceInstance, finalizer, description string, addAction bool) (*apiv1.ResourceInstance, error) UpdateResourceInstance(ri apiv1.Interface) (*apiv1.ResourceInstance, error) - CreateOrUpdateResource(ri apiv1.Interface) (*apiv1.ResourceInstance, error) + CreateOrUpdateResource(ri apiv1.Interface, opts ...UpdateOption) (*apiv1.ResourceInstance, error) CreateResourceInstance(ri apiv1.Interface) (*apiv1.ResourceInstance, error) PatchSubResource(ri apiv1.Interface, subResourceName string, patches []map[string]interface{}) (*apiv1.ResourceInstance, error) DeleteResourceInstance(ri apiv1.Interface) error @@ -831,6 +840,8 @@ func (c *ServiceClient) addResourceToCache(data *apiv1.ResourceInstance) { c.caches.AddCredentialRequestDefinition(data) case management.ApplicationProfileDefinitionGVK().Kind: c.caches.AddApplicationProfileDefinition(data) + case management.APIServiceGVK().Kind: + c.caches.AddAPIService(data) case management.APIServiceInstanceGVK().Kind: c.caches.AddAPIServiceInstance(data) case management.ComplianceRuntimeResultGVK().Kind: @@ -838,16 +849,52 @@ func (c *ServiceClient) addResourceToCache(data *apiv1.ResourceInstance) { } } +func WithExistingResourceInstance(existingRI *apiv1.ResourceInstance) UpdateOption { + return func(o *updateOptions) { + o.existingRI = existingRI + } +} + +func WithSkipSetSpecHash(skip bool) UpdateOption { + return func(o *updateOptions) { + o.skipSetSpecHash = skip + } +} + +func WithSkipXAgentDetailUpdate(skip bool) UpdateOption { + return func(o *updateOptions) { + o.skipXAgentDetailUpdate = skip + } +} + // updateORCreateResourceInstance -func (c *ServiceClient) updateSpecORCreateResourceInstance(data *apiv1.ResourceInstance) (*apiv1.ResourceInstance, error) { +func (c *ServiceClient) updateSpecORCreateResourceInstance(data *apiv1.ResourceInstance, opts ...UpdateOption) (*apiv1.ResourceInstance, error) { // default to post url := c.createAPIServerURL(data.GetKindLink()) method := coreapi.POST + // apply options + options := &updateOptions{} + for _, opt := range opts { + opt(options) + } + + if !options.skipSetSpecHash { + addSpecHashToResource(data) + } // check if the KIND and ID combo have an item in the cache - existingRI, err := c.getCachedResource(data) + var err error + existingRI := options.existingRI + if existingRI == nil { + existingRI, err = c.getCachedResource(data) + } + updateRI := true - updateAgentDetails := true + updateAgentDetails := !options.skipXAgentDetailUpdate + logger := c.logger.WithField("resourceName", data.Name).WithField("resourceKind", data.Kind) + if data.Metadata.ID != "" { + logger = logger.WithField("resourceID", data.Metadata.ID) + } if err == nil && existingRI != nil && existingRI.Metadata.Scope.Name == data.Metadata.Scope.Name { url = c.createAPIServerURL(data.GetSelfLink()) @@ -858,7 +905,7 @@ func (c *ServiceClient) updateSpecORCreateResourceInstance(data *apiv1.ResourceI oldHash, _ := util.GetAgentDetailsValue(existingRI, defs.AttrSpecHash) newHash, _ := util.GetAgentDetailsValue(data, defs.AttrSpecHash) if oldHash == newHash && existingRI.Title == data.Title && equalTags { - log.Debug("no updates to the hash or to the title") + logger.Debug("no updates to the hash or to the title") updateRI = false } @@ -866,13 +913,13 @@ func (c *ServiceClient) updateSpecORCreateResourceInstance(data *apiv1.ResourceI oldAgentDetails := util.GetAgentDetails(existingRI) newAgentDetails := util.GetAgentDetails(data) if util.MapsEqual(oldAgentDetails, newAgentDetails) { - log.Debug("no updates to the x-agent-details") + logger.Debug("no updates to the x-agent-details") updateAgentDetails = false } // if no changes altogether, return without update if !updateRI && !updateAgentDetails { - log.Trace("no updates made to the resource instance or to the x-agent-details.") + logger.Trace("no updates made to the resource instance or to the x-agent-details.") return existingRI, nil } @@ -940,7 +987,7 @@ func (c *ServiceClient) updateSpecORCreateResourceInstance(data *apiv1.ResourceI return newRI, err } -func (c *ServiceClient) CreateOrUpdateResource(data apiv1.Interface) (*apiv1.ResourceInstance, error) { +func (c *ServiceClient) CreateOrUpdateResource(data apiv1.Interface, opts ...UpdateOption) (*apiv1.ResourceInstance, error) { if data.GetMetadata().Scope.Name == "" { data.SetScopeName(c.cfg.GetEnvironmentName()) } @@ -949,7 +996,7 @@ func (c *ServiceClient) CreateOrUpdateResource(data apiv1.Interface) (*apiv1.Res return nil, err } - ri, err = c.updateSpecORCreateResourceInstance(ri) + ri, err = c.updateSpecORCreateResourceInstance(ri, opts...) return ri, err } diff --git a/pkg/apic/client_test.go b/pkg/apic/client_test.go index 03b901e4f..3c7c39aa6 100644 --- a/pkg/apic/client_test.go +++ b/pkg/apic/client_test.go @@ -254,15 +254,15 @@ func TestUpdateSpecORCreateResourceInstance(t *testing.T) { }, "should not update ARD as hash is unchanged": { gvk: management.AccessRequestDefinitionGVK(), - oldHash: "1234", - newHash: "1234", + oldHash: "645223143103797797", + newHash: "645223143103797797", apiResponses: []api.MockResponse{}, expectedAttrVal: "existing", }, "should not update CRD as hash is unchanged": { gvk: management.CredentialRequestDefinitionGVK(), - oldHash: "1234", - newHash: "1234", + oldHash: "645223143103797797", + newHash: "645223143103797797", apiResponses: []api.MockResponse{}, expectedAttrVal: "existing", }, diff --git a/pkg/apic/mock/mockclient.go b/pkg/apic/mock/mockclient.go index 9e4de25b0..425b6670e 100644 --- a/pkg/apic/mock/mockclient.go +++ b/pkg/apic/mock/mockclient.go @@ -26,6 +26,7 @@ type Client struct { GetAPIServiceInstancesMock func(queryParams map[string]string, URL string) ([]*management.APIServiceInstance, error) GetAPIV1ResourceInstancesMock func(queryParams map[string]string, URL string) ([]*v1.ResourceInstance, error) GetAPIV1ResourceInstancesWithPageSizeMock func(queryParams map[string]string, URL string, pageSize int) ([]*v1.ResourceInstance, error) + GetAPIV1ResourceCountMock func(URL string) (int, error) GetAPIServiceByNameMock func(serviceName string) (*management.APIService, error) GetAPIServiceInstanceByNameMock func(serviceInstanceName string) (*management.APIServiceInstance, error) GetAPIRevisionByNameMock func(serviceRevisionName string) (*management.APIServiceRevision, error) @@ -45,7 +46,7 @@ type Client struct { CreateResourceMock func(url string, bts []byte) (*v1.ResourceInstance, error) UpdateResourceMock func(url string, bts []byte) (*v1.ResourceInstance, error) UpdateResourceFinalizerMock func(res *v1.ResourceInstance, finalizer, description string, addAction bool) (*v1.ResourceInstance, error) - CreateOrUpdateResourceMock func(v1.Interface) (*v1.ResourceInstance, error) + CreateOrUpdateResourceMock func(v1.Interface, ...apic.UpdateOption) (*v1.ResourceInstance, error) GetEntitlementsMock func() (map[string]interface{}, error) } @@ -112,6 +113,14 @@ func (m *Client) GetAPIV1ResourceInstances(queryParams map[string]string, URL st return nil, nil } +// GetAPIV1ResourceCount - +func (m *Client) GetAPIV1ResourceCount(URL string) (int, error) { + if m.GetAPIV1ResourceCountMock != nil { + return m.GetAPIV1ResourceCountMock(URL) + } + return 0, nil +} + // GetAPIServiceByName - func (m *Client) GetAPIServiceByName(serviceName string) (*management.APIService, error) { if m.GetAPIServiceByNameMock != nil { @@ -319,9 +328,9 @@ func (m *Client) UpdateResourceFinalizer(res *v1.ResourceInstance, finalizer, de } // CreateOrUpdateResource - -func (m *Client) CreateOrUpdateResource(iface v1.Interface) (*v1.ResourceInstance, error) { +func (m *Client) CreateOrUpdateResource(iface v1.Interface, opts ...apic.UpdateOption) (*v1.ResourceInstance, error) { if m.CreateOrUpdateResourceMock != nil { - return m.CreateOrUpdateResourceMock(iface) + return m.CreateOrUpdateResourceMock(iface, opts...) } return nil, nil } diff --git a/pkg/apic/provisioning/accessrequestdefinitionbuilder.go b/pkg/apic/provisioning/accessrequestdefinitionbuilder.go index f9879a60b..bfef563a7 100644 --- a/pkg/apic/provisioning/accessrequestdefinitionbuilder.go +++ b/pkg/apic/provisioning/accessrequestdefinitionbuilder.go @@ -4,7 +4,6 @@ import ( "fmt" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" - "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/util" ) @@ -146,12 +145,11 @@ func (a *accessRequestDef) Register() (*management.AccessRequestDefinition, erro } } - hashInt, _ := util.ComputeHash(spec) - // put back in spec the complete request schema spec.Schema = a.requestSchema if a.name == "" { + hashInt, _ := util.ComputeHash(spec) a.name = util.ConvertUnitToString(hashInt) } @@ -165,7 +163,5 @@ func (a *accessRequestDef) Register() (*management.AccessRequestDefinition, erro } } - util.SetAgentDetailsKey(ard, definitions.AttrSpecHash, fmt.Sprintf("%v", hashInt)) - return a.registerFunc(ard) } diff --git a/pkg/apic/provisioning/applicationprofiledefinitionbuilder.go b/pkg/apic/provisioning/applicationprofiledefinitionbuilder.go index c99eeedf6..7c689bc00 100644 --- a/pkg/apic/provisioning/applicationprofiledefinitionbuilder.go +++ b/pkg/apic/provisioning/applicationprofiledefinitionbuilder.go @@ -4,7 +4,6 @@ import ( "fmt" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" - "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/util" ) @@ -79,12 +78,11 @@ func (a *applicationProfileDef) Register() (*management.ApplicationProfileDefini Schema: a.requestSchema, } - hashInt, _ := util.ComputeHash(spec) - // put back in spec the complete request schema spec.Schema = a.requestSchema if a.name == "" { + hashInt, _ := util.ComputeHash(spec) a.name = util.ConvertUnitToString(hashInt) } @@ -92,7 +90,5 @@ func (a *applicationProfileDef) Register() (*management.ApplicationProfileDefini ard.Title = a.title ard.Spec = spec - util.SetAgentDetailsKey(ard, definitions.AttrSpecHash, fmt.Sprintf("%v", hashInt)) - return a.registerFunc(ard) } diff --git a/pkg/apic/provisioning/credentialrequestdefinitionbuilder.go b/pkg/apic/provisioning/credentialrequestdefinitionbuilder.go index 29f7f69aa..9580f36f7 100644 --- a/pkg/apic/provisioning/credentialrequestdefinitionbuilder.go +++ b/pkg/apic/provisioning/credentialrequestdefinitionbuilder.go @@ -5,7 +5,6 @@ import ( "maps" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" - "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/util" ) @@ -183,7 +182,6 @@ func (c *credentialRequestDef) Register() (*management.CredentialRequestDefiniti }, }, } - hashInt, _ := util.ComputeHash(spec) if c.period > 0 { spec.Provision.Policies.Expiry = &management.CredentialRequestDefinitionSpecProvisionPoliciesExpiry{ @@ -203,8 +201,6 @@ func (c *credentialRequestDef) Register() (*management.CredentialRequestDefiniti crd.Spec = spec - util.SetAgentDetailsKey(crd, definitions.AttrSpecHash, fmt.Sprintf("%v", hashInt)) - d := util.GetAgentDetails(crd) for key, value := range c.agentDetails { d[key] = value diff --git a/pkg/apic/resourcePagination.go b/pkg/apic/resourcePagination.go index 5966389ba..385787957 100644 --- a/pkg/apic/resourcePagination.go +++ b/pkg/apic/resourcePagination.go @@ -3,6 +3,7 @@ package apic import ( "encoding/json" "fmt" + "net/http" "strconv" "strings" @@ -61,6 +62,34 @@ func (c *ServiceClient) GetAPIV1ResourceInstances(queryParams map[string]string, return c.GetAPIV1ResourceInstancesWithPageSize(queryParams, url, c.cfg.GetPageSize()) } +// GetAPIV1ResourceCount issues a HEAD request and returns the total resource count +// from the X-Axway-total-count response header. Returns 0 if the header is absent. +func (c *ServiceClient) GetAPIV1ResourceCount(url string) (int, error) { + if !strings.HasPrefix(url, c.cfg.GetAPIServerURL()) && !strings.HasPrefix(url, c.cfg.GetAPIServerVersionURL()) { + url = c.createAPIServerURL(url) + } + + response, err := c.executeAPI(http.MethodHead, url, nil, nil, nil) + if err != nil { + return 0, err + } + if response.Code != http.StatusOK { + return 0, fmt.Errorf("HEAD %s returned %d", url, response.Code) + } + + vals := response.Headers[http.CanonicalHeaderKey("X-Axway-total-count")] + if len(vals) == 0 { + return 0, nil + } + + count, err := strconv.Atoi(vals[0]) + if err != nil { + return 0, err + } + + return count, nil +} + func (c *ServiceClient) getPageSize(url string) (int, bool) { c.pageSizeMutex.Lock() defer c.pageSizeMutex.Unlock() diff --git a/pkg/compliance/compliance_test.go b/pkg/compliance/compliance_test.go index 2fdf5709f..97252b0f3 100644 --- a/pkg/compliance/compliance_test.go +++ b/pkg/compliance/compliance_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/Axway/agent-sdk/pkg/agent" + "github.com/Axway/agent-sdk/pkg/apic" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -61,7 +62,7 @@ func TestCompliance(t *testing.T) { return nil } - apicMockCli.CreateOrUpdateResourceMock = func(ri v1.Interface) (*v1.ResourceInstance, error) { + apicMockCli.CreateOrUpdateResourceMock = func(ri v1.Interface, _ ...apic.UpdateOption) (*v1.ResourceInstance, error) { resInst, _ := ri.AsInstance() crr := management.NewComplianceRuntimeResult("", "") crr.FromInstance(resInst) diff --git a/pkg/compliance/job.go b/pkg/compliance/job.go index f18d7c7ee..c220c27f6 100644 --- a/pkg/compliance/job.go +++ b/pkg/compliance/job.go @@ -5,6 +5,7 @@ import ( "time" "github.com/Axway/agent-sdk/pkg/agent" + "github.com/Axway/agent-sdk/pkg/apic" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -65,7 +66,7 @@ func (j *runtimeComplianceJob) publishResources(results *runtimeResults) { WithField("riskScore", result.RiskScore) logger.Debug("creating/updating compliance runtime result") - _, err = agent.GetCentralClient().CreateOrUpdateResource(crr) + _, err = agent.GetCentralClient().CreateOrUpdateResource(crr, apic.WithSkipSetSpecHash(true)) if err != nil { logger.WithError(err).Error("failed to create/update runtime compliance result") continue diff --git a/pkg/harvester/harvesterclient.go b/pkg/harvester/harvesterclient.go index 1e068c5ca..97f56275b 100644 --- a/pkg/harvester/harvesterclient.go +++ b/pkg/harvester/harvesterclient.go @@ -55,11 +55,13 @@ type Config struct { // Client for connecting to harvester type Client struct { - Cfg *Config - Client api.Client - URL string - logger log.FieldLogger - skipPublish bool + Cfg *Config + Client api.Client + URL string + logger log.FieldLogger + skipPublish bool + publishingLocker func() + publishingUnLocker func() } // NewConfig creates a config for harvester connections @@ -81,8 +83,10 @@ func NewConfig(cfg config.CentralConfig, getToken auth.TokenGetter, seq events.S } } +type option func(*Client) + // NewClient creates a new harvester client -func NewClient(cfg *Config) *Client { +func NewClient(cfg *Config, opts ...option) *Client { if cfg.Protocol == "" { cfg.Protocol = "https" } @@ -96,13 +100,24 @@ func NewClient(cfg *Config) *Client { harvesterURL := fmt.Sprintf("%s://%s:%d/events", cfg.Protocol, cfg.Host, int(cfg.Port)) - return &Client{ + c := &Client{ URL: harvesterURL, Cfg: cfg, Client: newSingleEntryClient(cfg), logger: logger, skipPublish: cfg.skipPublish, } + for _, opt := range opts { + opt(c) + } + return c +} + +func WithPublishLock(publishingLocker, publishingUnLocker func()) option { + return func(c *Client) { + c.publishingLocker = publishingLocker + c.publishingUnLocker = publishingUnLocker + } } // ReceiveSyncEvents fetches events based on the sequence id and watch topic self link, and publishes the events to the event channel @@ -209,9 +224,12 @@ func (h *Client) EventCatchUp(parentCtx context.Context, link string, events cha return nil } - // TODO should this timeout duration be changed? - // allow up to a minute to sync events - ctx, cancel := context.WithTimeout(parentCtx, time.Minute) + if h.publishingLocker != nil && h.publishingUnLocker != nil { + h.publishingLocker() + defer h.publishingUnLocker() + } + + ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) defer cancel() return h.syncEvents(ctx, link, events) } diff --git a/pkg/migrate/apisimigration_test.go b/pkg/migrate/apisimigration_test.go index a6734150e..73901f64d 100644 --- a/pkg/migrate/apisimigration_test.go +++ b/pkg/migrate/apisimigration_test.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "github.com/Axway/agent-sdk/pkg/apic" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -139,7 +140,7 @@ func (m *mockAPISIMigClient) UpdateResourceInstance(ri apiv1.Interface) (*apiv1. return r, err } -func (m *mockAPISIMigClient) CreateOrUpdateResource(data apiv1.Interface) (*apiv1.ResourceInstance, error) { +func (m *mockAPISIMigClient) CreateOrUpdateResource(data apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { return nil, nil } diff --git a/pkg/migrate/ardmigration_test.go b/pkg/migrate/ardmigration_test.go index 53499ba83..38f53dfce 100644 --- a/pkg/migrate/ardmigration_test.go +++ b/pkg/migrate/ardmigration_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/Axway/agent-sdk/pkg/apic" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/config" @@ -45,7 +46,7 @@ func (m mockArdMigClient) UpdateResourceInstance(ri apiv1.Interface) (*apiv1.Res return r, err } -func (m mockArdMigClient) CreateOrUpdateResource(data apiv1.Interface) (*apiv1.ResourceInstance, error) { +func (m mockArdMigClient) CreateOrUpdateResource(data apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { return nil, nil } diff --git a/pkg/migrate/attributemigration.go b/pkg/migrate/attributemigration.go index 4681dbcb9..8d46a8833 100644 --- a/pkg/migrate/attributemigration.go +++ b/pkg/migrate/attributemigration.go @@ -6,6 +6,7 @@ import ( "regexp" "sync" + "github.com/Axway/agent-sdk/pkg/apic" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -46,7 +47,7 @@ type client interface { ExecuteAPI(method, url string, queryParam map[string]string, buffer []byte) ([]byte, error) GetAPIV1ResourceInstances(query map[string]string, URL string) ([]*apiv1.ResourceInstance, error) UpdateResourceInstance(ri apiv1.Interface) (*apiv1.ResourceInstance, error) - CreateOrUpdateResource(data apiv1.Interface) (*apiv1.ResourceInstance, error) + CreateOrUpdateResource(data apiv1.Interface, opts ...apic.UpdateOption) (*apiv1.ResourceInstance, error) CreateSubResource(rm apiv1.ResourceMeta, subs map[string]interface{}) error DeleteResourceInstance(ri apiv1.Interface) error GetResource(url string) (*apiv1.ResourceInstance, error) diff --git a/pkg/migrate/attributemigration_test.go b/pkg/migrate/attributemigration_test.go index 520d65dd0..99d4b7790 100644 --- a/pkg/migrate/attributemigration_test.go +++ b/pkg/migrate/attributemigration_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "testing" + "github.com/Axway/agent-sdk/pkg/apic" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -172,7 +173,7 @@ func (m *mockAttrMigClient) CreateSubResource(_ apiv1.ResourceMeta, _ map[string return nil } -func (m *mockAttrMigClient) CreateOrUpdateResource(data apiv1.Interface) (*apiv1.ResourceInstance, error) { +func (m *mockAttrMigClient) CreateOrUpdateResource(data apiv1.Interface, _ ...apic.UpdateOption) (*apiv1.ResourceInstance, error) { return m.execRes, nil } From 5d5047ac108457dc4df8a29ae051e87b5c2941b1 Mon Sep 17 00:00:00 2001 From: sbolosan <47189541+sbolosan@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:04:20 -1000 Subject: [PATCH 20/22] APIGOV-31452 - Send New Insights Event Formats (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * APIGOV-31452 - send new insights events formats * APIGOV-31452 - updates on access request * APIGOV-31452 - add deep logging for event building * APIGOV-31452 - add missing attribs for service revision id and agent version * APIGOV-31452 - updated metric v3 events * APIGOV-31452 - update for product owner * APIGOV-31452 - fall back to apiserviceinstance by name * APIGOV-31452 - add logging for unmarshaling * APIGOV-31452 - update for embedded shape * APIGOV-31452 - match the actual _embedded.metadata.references shape from the API server * APIGOV-31452 - addition of owner * APIGOV-31452 - add SetReporter builder method to CentralMetricBuilder * APIGOV-31452 - update for published product watch topic * APIGOV-31452 - revert watch topic logic * APIGOV-31452 - get published product owner from embedded * APIGOV-31452 - update unit test coverage * APIGOV-31452 - add api metric for agents explicitly calling * APIGOV-31452 - user id path * APIGOV-31452 - update test coverage * APIGOV-31452 - update sonar fixes * APIGOV-31452 - update embed for x agent details apiservice fetch * APIGOV-31452 - update leg with consumer details * APIGOV-31452 - remove consumer details * APIGOV-31452 - review * APIGOV-31452 - set proxy funcs for on prem agents * APIGOV-31452 - add backwards compatibility for leg * APIGOV-31452 - update product block * APIGOV-31452 - update rev id for on prem * APIGOV-31452 - apiservicerev setter for some on prem agents * APIGOV-31452 - update transaction fields * APIGOV-31452 - update leg direction and proxy * APIGOV-31452 - update proxy * APIGOV-31452 - expose SetLegProxy for controller enrichment * APIGOV-31452 - update on legs * APIGOV-31452 - set owner from man app * APIGOV-31452 - product owner fix * APIGOV-31452 - update source and ip for v7 fixes * APIGOV-31452 - remove flow header * APIGOV-31452 - review * APIGOV-31452 - review and unit test coverage * APIGOV-31452 - update tests and comments * APIGOV-31452 - PR comments and proxy updates * APIGOV-31452 - update based on PR comments * fix mocks * fix rebase error --------- Co-authored-by: Jason Collins --- pkg/agent/cache/accessrequest.go | 3 + pkg/agent/discoverycache.go | 8 +- pkg/agent/discoverycache_test.go | 45 +- pkg/agent/handler/credential_test.go | 17 +- pkg/agent/handler/traceaccessrequest.go | 8 +- pkg/agent/handler/traceaccessrequest_test.go | 201 ++- pkg/apic/apiserver/models/api/v1/owner.go | 9 + .../apiserver/models/api/v1/owner_test.go | 45 +- .../apiserver/models/api/v1/resourcemeta.go | 15 + .../models/api/v1/resourcemeta_test.go | 173 ++- pkg/apic/apiserver/models/api/v1/types.go | 23 + pkg/apic/apiservicerevision.go | 6 +- pkg/apic/resourcePagination.go | 4 +- pkg/traceability/httpclient.go | 8 +- pkg/traceability/httpclient_test.go | 88 ++ pkg/transaction/contract_test.go | 207 +++ pkg/transaction/definitions.go | 4 + pkg/transaction/eventgenerator.go | 173 ++- pkg/transaction/eventgenerator_test.go | 245 +++- pkg/transaction/logeventbuilder.go | 29 + pkg/transaction/logeventbuilder_test.go | 447 +++--- pkg/transaction/metric/apimetric.go | 2 + pkg/transaction/metric/centralmetric.go | 109 +- pkg/transaction/metric/centralmetric_test.go | 97 ++ pkg/transaction/metric/contract_test.go | 214 +++ pkg/transaction/metric/definition.go | 13 +- pkg/transaction/metric/metricscollector.go | 128 +- .../metric/metricscollector_test.go | 810 +++++++---- pkg/transaction/metric/units.go | 1 + pkg/transaction/metric/util.go | 202 ++- pkg/transaction/metric/util_test.go | 369 ++++- pkg/transaction/models/definitions.go | 14 +- pkg/transaction/util/ownerresolver.go | 99 ++ pkg/transaction/util/ownerresolver_test.go | 166 +++ pkg/transaction/v2event.go | 539 +++++++ pkg/transaction/v2event_test.go | 1279 +++++++++++++++++ 36 files changed, 4946 insertions(+), 854 deletions(-) create mode 100644 pkg/traceability/httpclient_test.go create mode 100644 pkg/transaction/contract_test.go create mode 100644 pkg/transaction/metric/centralmetric_test.go create mode 100644 pkg/transaction/metric/contract_test.go create mode 100644 pkg/transaction/util/ownerresolver.go create mode 100644 pkg/transaction/util/ownerresolver_test.go create mode 100644 pkg/transaction/v2event.go create mode 100644 pkg/transaction/v2event_test.go diff --git a/pkg/agent/cache/accessrequest.go b/pkg/agent/cache/accessrequest.go index 4c314d5ee..209427eb4 100644 --- a/pkg/agent/cache/accessrequest.go +++ b/pkg/agent/cache/accessrequest.go @@ -41,6 +41,9 @@ func (c *cacheManager) AddAccessRequest(ri *v1.ResourceInstance) { instID = instRef.ID instance, _ := c.GetAPIServiceInstanceByID(instID) + if instance == nil && ar.Spec.ApiServiceInstance != "" { + instance, _ = c.GetAPIServiceInstanceByName(ar.Spec.ApiServiceInstance) + } apiID := "" apiStage := "" apiVersion := "" diff --git a/pkg/agent/discoverycache.go b/pkg/agent/discoverycache.go index 2c946bfe1..5c21ca8ab 100644 --- a/pkg/agent/discoverycache.go +++ b/pkg/agent/discoverycache.go @@ -254,8 +254,12 @@ func (dc *discoveryCache) buildResourceFunc(filter management.WatchTopicSpecFilt logger := dc.logger.WithField("kind", filter.Kind) logger.Tracef("fetching %s and updating cache", filter.Kind) - url := ri.GetKindLink() - resources, err := dc.client.GetAPIV1ResourceInstances(nil, url) + var queryParams map[string]string + if filter.Kind == management.AccessRequestGVK().Kind { + queryParams = map[string]string{"embed": "metadata.references"} + } + + resources, err := dc.client.GetAPIV1ResourceInstances(queryParams, ri.GetKindLink()) if err != nil { return fmt.Errorf("failed to fetch resources of kind %s: %s", filter.Kind, err) } diff --git a/pkg/agent/discoverycache_test.go b/pkg/agent/discoverycache_test.go index 6448efe1d..7016b9e69 100644 --- a/pkg/agent/discoverycache_test.go +++ b/pkg/agent/discoverycache_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "fmt" "strings" "sync" "testing" @@ -18,7 +17,7 @@ import ( const envName = "mockEnv" -func TestDiscoveryCache_execute(t *testing.T) { +func TestDiscoveryCacheExecute(t *testing.T) { tests := map[string]struct { agentType config.AgentType wt *management.WatchTopic @@ -116,6 +115,16 @@ func TestDiscoveryCache_execute(t *testing.T) { } else { assert.False(t, migration.called) } + + // AccessRequest fetch must include embed=metadata.references. + if tc.accessReqCount > 0 { + arParams := c.queryParams["accessrequests"] + assert.Equal(t, "metadata.references", arParams["embed"], "accessrequests must be fetched with embed=metadata.references") + } + // APIService fetch must not include embed param; x-agent-details is already in the response. + if svcParams, ok := c.queryParams["apiservices"]; ok { + assert.Empty(t, svcParams["embed"], "apiservices must not include embed param") + } }) } } @@ -181,21 +190,33 @@ type mockRIClient struct { accessReqs []*apiv1.ResourceInstance creds []*apiv1.ResourceInstance err error + // queryParams records the query params passed per URL fragment (kind name) + queryParams map[string]map[string]string } -func (m mockRIClient) GetAPIV1ResourceCount(_ string) (int, error) { return 0, nil } +func (m *mockRIClient) GetAPIV1ResourceCount(_ string) (int, error) { + return 0, nil +} -func (m mockRIClient) GetAPIV1ResourceInstances(_ map[string]string, URL string) ([]*apiv1.ResourceInstance, error) { - fmt.Println(URL) - if strings.Contains(URL, "apiservices") { - return m.svcs, m.err - } else if strings.Contains(URL, "managedapplications") { - return m.managedApps, m.err - } else if strings.Contains(URL, "managedapplicationprofiles") { +func (m *mockRIClient) GetAPIV1ResourceInstances(query map[string]string, URL string) ([]*apiv1.ResourceInstance, error) { + if m.queryParams == nil { + m.queryParams = make(map[string]map[string]string) + } + switch { + case strings.Contains(URL, "managedapplicationprofiles"): + m.queryParams["managedapplicationprofiles"] = query return m.manAppProfs, m.err - } else if strings.Contains(URL, "accessrequests") { + case strings.Contains(URL, "managedapplications"): + m.queryParams["managedapplications"] = query + return m.managedApps, m.err + case strings.Contains(URL, "apiservices"): + m.queryParams["apiservices"] = query + return m.svcs, m.err + case strings.Contains(URL, "accessrequests"): + m.queryParams["accessrequests"] = query return m.accessReqs, m.err - } else if strings.Contains(URL, "credentials") { + case strings.Contains(URL, "credentials"): + m.queryParams["credentials"] = query return m.creds, m.err } return make([]*apiv1.ResourceInstance, 0), m.err diff --git a/pkg/agent/handler/credential_test.go b/pkg/agent/handler/credential_test.go index b9c2edf1c..f08cb0f6b 100644 --- a/pkg/agent/handler/credential_test.go +++ b/pkg/agent/handler/credential_test.go @@ -24,6 +24,7 @@ import ( "github.com/Axway/agent-sdk/pkg/watchmanager/proto" ) + func TestCredentialHandler(t *testing.T) { crdRI, _ := crd.AsInstance() @@ -196,7 +197,7 @@ func TestCredentialHandler(t *testing.T) { } } -func TestCredentialHandler_deleting(t *testing.T) { +func TestCredentialHandlerDeleting(t *testing.T) { crdRI, _ := crd.AsInstance() tests := []struct { @@ -311,7 +312,7 @@ func TestCredentialHandler_deleting(t *testing.T) { } } -func TestCredentialHandler_update(t *testing.T) { +func TestCredentialHandlerUpdate(t *testing.T) { crdRI, _ := crd.AsInstance() tests := []struct { @@ -412,7 +413,7 @@ func TestCredentialHandler_update(t *testing.T) { } } -func TestCredentialHandler_wrong_kind(t *testing.T) { +func TestCredentialHandlerWrongKind(t *testing.T) { c := &mockClient{} p := &mockCredProv{} handler := NewCredentialHandler(p, c, nil) @@ -425,7 +426,7 @@ func TestCredentialHandler_wrong_kind(t *testing.T) { assert.Nil(t, err) } -func Test_creds(t *testing.T) { +func TestCreds(t *testing.T) { c := provCreds{ managedApp: "app-name", credType: "api-key", @@ -799,7 +800,7 @@ func decrypt(pk string, alg, hash string, data map[string]interface{}) map[strin return data } -func Test_encrypt(t *testing.T) { +func TestEncrypt(t *testing.T) { var crdSchema = `{ "type": "object", "$schema": "http://json-schema.org/draft-07/schema#", @@ -850,7 +851,7 @@ func Test_encrypt(t *testing.T) { }, { name: "should encrypt when the algorithm is RSA-OAEP", - alg: "RSA-OAEP", + alg: util.RsaOaep, hash: "SHA256", publicKey: pub, privateKey: priv, @@ -866,7 +867,7 @@ func Test_encrypt(t *testing.T) { { name: "should return an error when the hash is unknown", hasErr: true, - alg: "RSA-OAEP", + alg: util.RsaOaep, hash: "fake", publicKey: pub, privateKey: priv, @@ -874,7 +875,7 @@ func Test_encrypt(t *testing.T) { { name: "should return an error when the public key cannot be parsed", hasErr: true, - alg: "RSA-OAEP", + alg: util.RsaOaep, hash: "SHA256", publicKey: "fake", privateKey: priv, diff --git a/pkg/agent/handler/traceaccessrequest.go b/pkg/agent/handler/traceaccessrequest.go index be85e1165..578adefa7 100644 --- a/pkg/agent/handler/traceaccessrequest.go +++ b/pkg/agent/handler/traceaccessrequest.go @@ -54,8 +54,12 @@ func (h *traceAccessRequestHandler) Handle(ctx context.Context, meta *proto.Even if h.shouldProcessForAgent(ar.Status, ar.Metadata.State) { cachedAccessReq := h.cache.GetAccessRequest(resource.Metadata.ID) - if cachedAccessReq == nil { - h.cache.AddAccessRequest(resource) + if cachedAccessReq == nil || len(cachedAccessReq.Metadata.References) == 0 { + enriched, err := h.client.GetResource(resource.GetSelfLink() + "?embed=metadata.references") + if err != nil || enriched == nil { + enriched = resource + } + h.cache.AddAccessRequest(enriched) } } return nil diff --git a/pkg/agent/handler/traceaccessrequest_test.go b/pkg/agent/handler/traceaccessrequest_test.go index 403bcd919..d5fad1cc2 100644 --- a/pkg/agent/handler/traceaccessrequest_test.go +++ b/pkg/agent/handler/traceaccessrequest_test.go @@ -1,79 +1,49 @@ package handler import ( + "fmt" "testing" + "github.com/stretchr/testify/assert" + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" - "github.com/stretchr/testify/assert" ) -func TestTraceAccessRequestHandler_wrong_kind(t *testing.T) { - cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) - c := &mockClient{} - handler := NewTraceAccessRequestHandler(cm, c) - ri := &apiv1.ResourceInstance{ - ResourceMeta: apiv1.ResourceMeta{ - GroupVersionKind: management.EnvironmentGVK(), - }, - } - err := handler.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) - assert.Nil(t, err) -} - -func TestTraceAccessRequestTraceHandler(t *testing.T) { - cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) - c := &mockClient{} - handler := NewTraceAccessRequestHandler(cm, c) - ar := &management.AccessRequest{ +func makeTraceAR(id, name, appName, instanceID string) *management.AccessRequest { + return &management.AccessRequest{ ResourceMeta: apiv1.ResourceMeta{ GroupVersionKind: management.AccessRequestGVK(), Metadata: apiv1.Metadata{ - ID: "ar", + ID: id, References: []apiv1.Reference{ { - ID: "instanceId", + ID: instanceID, Name: "instance", Group: management.APIServiceInstanceGVK().Group, Kind: management.APIServiceInstanceGVK().Kind, }, }, }, - Name: "ar", + Name: name, }, Spec: management.AccessRequestSpec{ - ManagedApplication: "app", + ManagedApplication: appName, ApiServiceInstance: "instance", }, - References: []interface{}{ - management.AccessRequestReferencesSubscription{ - Kind: defs.Subscription, - Name: "catalog/subscription-name", - }, - }, + Status: &apiv1.ResourceStatus{Level: "Success"}, } - ri, _ := ar.AsInstance() - - // no status - err := handler.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) - assert.Nil(t, err) - assert.Equal(t, []string{}, cm.GetAccessRequestCacheKeys()) - - ar.Status = &apiv1.ResourceStatus{ - Level: "Success", - } - ri, _ = ar.AsInstance() +} +func TestTraceAccessRequestTraceHandler(t *testing.T) { inst := &apiv1.ResourceInstance{ ResourceMeta: apiv1.ResourceMeta{ - Metadata: apiv1.Metadata{ - ID: "instanceId", - }, - Name: "instance", + Metadata: apiv1.Metadata{ID: "instanceId"}, + Name: "instance", SubResources: map[string]interface{}{ defs.XAgentDetails: map[string]interface{}{ defs.AttrExternalAPIID: "api", @@ -81,39 +51,138 @@ func TestTraceAccessRequestTraceHandler(t *testing.T) { }, }, } - cm.AddAPIServiceInstance(inst) - managedApp := &apiv1.ResourceInstance{ - ResourceMeta: apiv1.ResourceMeta{ - Metadata: apiv1.Metadata{ - ID: "app", + ar := makeTraceAR("ar", "ar", "app", "instanceId") + ri, _ := ar.AsInstance() + + // enriched version of the same AR (simulates ?embed=metadata.references response) + arEnriched := makeTraceAR("ar", "ar", "app", "instanceId") + arEnriched.ResourceMeta.Embedded = map[string]apiv1.EmbeddedReferences{ + "publishedproducts": { + References: []apiv1.EmbeddedReference{ + {Kind: "PublishedProduct", Name: "pp1"}, }, - Name: "app", }, } - cm.AddManagedApplication(managedApp) + enrichedRI, _ := arEnriched.AsInstance() - c.getRI = &apiv1.ResourceInstance{ + // simulates a resource that was cached via the error-fallback path (no embedded references) + arNoRefs := &management.AccessRequest{ ResourceMeta: apiv1.ResourceMeta{ - Metadata: apiv1.Metadata{ - ID: "subscription-id", - }, - Name: "subscription-name", + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ID: "ar"}, + Name: "ar", + }, + Spec: management.AccessRequestSpec{ + ManagedApplication: "app", + ApiServiceInstance: "instance", + }, + Status: &apiv1.ResourceStatus{Level: "Success"}, + } + riNoRefs, _ := arNoRefs.AsInstance() + + noStatusAR := &management.AccessRequest{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.AccessRequestGVK(), + Metadata: apiv1.Metadata{ID: "ar2"}, + Name: "ar2", + }, + } + noStatusRI, _ := noStatusAR.AsInstance() + + wrongKindRI := &apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{ + GroupVersionKind: management.EnvironmentGVK(), }, } - err = handler.Handle(NewEventContext(proto.Event_CREATED, nil, ri.Kind, ri.Name), nil, ri) - assert.Nil(t, err) - cachedAR := cm.GetAccessRequest("ar") - assert.NotNil(t, cachedAR) + tests := map[string]struct { + action proto.Event_Type + ri *apiv1.ResourceInstance + getRI *apiv1.ResourceInstance + getErr error + cachedRI *apiv1.ResourceInstance + expectCached bool + expectEnriched bool + }{ + "wrong kind - no-op": { + action: proto.Event_CREATED, + ri: wrongKindRI, + expectCached: false, + }, + "no status - not cached": { + action: proto.Event_CREATED, + ri: noStatusRI, + expectCached: false, + }, + "created, GET succeeds - enriched RI cached": { + action: proto.Event_CREATED, + ri: ri, + getRI: enrichedRI, + expectCached: true, + expectEnriched: true, + }, + "created, GET fails - watch RI cached as fallback": { + action: proto.Event_CREATED, + ri: ri, + getErr: fmt.Errorf("network error"), + expectCached: true, + }, + "created, GET returns nil - watch RI cached as fallback": { + action: proto.Event_CREATED, + ri: ri, + getRI: nil, + expectCached: true, + }, + "created, already cached with references - no GET call": { + action: proto.Event_CREATED, + ri: ri, + cachedRI: ri, + expectCached: true, + }, + "updated, cached without references - re-fetches enriched RI": { + action: proto.Event_UPDATED, + ri: ri, + cachedRI: riNoRefs, + getRI: enrichedRI, + expectCached: true, + expectEnriched: true, + }, + "deleted - removed from cache": { + action: proto.Event_DELETED, + ri: ri, + cachedRI: ri, + expectCached: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + cm.AddAPIServiceInstance(inst) + cm.AddManagedApplication(&apiv1.ResourceInstance{ + ResourceMeta: apiv1.ResourceMeta{Metadata: apiv1.Metadata{ID: "app"}, Name: "app"}, + }) - cachedAR = cm.GetAccessRequestByAppAndAPI("app", "api", "") - assert.NotNil(t, cachedAR) + if tc.cachedRI != nil { + cm.AddAccessRequest(tc.cachedRI) + } - err = handler.Handle(NewEventContext(proto.Event_DELETED, nil, ri.Kind, ri.Name), nil, ri) - assert.Nil(t, err) + c := &mockClient{getRI: tc.getRI, getErr: tc.getErr} + handler := NewTraceAccessRequestHandler(cm, c) - cachedAR = cm.GetAccessRequest("ar") - assert.Nil(t, cachedAR) + err := handler.Handle(NewEventContext(tc.action, nil, tc.ri.Kind, tc.ri.Name), nil, tc.ri) + assert.Nil(t, err) + cached := cm.GetAccessRequest("ar") + if tc.expectCached { + assert.NotNil(t, cached) + if tc.expectEnriched && tc.getRI != nil { + assert.Equal(t, enrichedRI.Embedded, cached.Embedded) + } + } else { + assert.Nil(t, cached) + } + }) + } } diff --git a/pkg/apic/apiserver/models/api/v1/owner.go b/pkg/apic/apiserver/models/api/v1/owner.go index 86400e9fd..c35631d35 100644 --- a/pkg/apic/apiserver/models/api/v1/owner.go +++ b/pkg/apic/apiserver/models/api/v1/owner.go @@ -9,6 +9,10 @@ type Organization struct { ID string `json:"id"` } +type OwnerUser struct { + ID string `json:"id"` +} + // OwnerType - type OwnerType uint @@ -29,6 +33,7 @@ type Owner struct { Type OwnerType `json:"type,omitempty"` ID string `json:"id"` Organization Organization `json:"organization,omitempty"` + User *OwnerUser `json:"user,omitempty"` } // SetType sets the type of the owner @@ -53,6 +58,7 @@ func (o *Owner) MarshalJSON() ([]byte, error) { Type string `json:"type,omitempty"` ID string `json:"id"` Organization *Organization `json:"organization,omitempty"` + User *OwnerUser `json:"user,omitempty"` }{} aux.Type = t @@ -62,6 +68,7 @@ func (o *Owner) MarshalJSON() ([]byte, error) { ID: o.Organization.ID, } } + aux.User = o.User return json.Marshal(aux) } @@ -72,6 +79,7 @@ func (o *Owner) UnmarshalJSON(bytes []byte) error { Type string `json:"type,omitempty"` ID string `json:"id"` Organization Organization `json:"organization,omitempty"` + User *OwnerUser `json:"user,omitempty"` }{} if err := json.Unmarshal(bytes, &aux); err != nil { @@ -89,6 +97,7 @@ func (o *Owner) UnmarshalJSON(bytes []byte) error { o.Type = ownerType o.ID = aux.ID o.Organization = aux.Organization + o.User = aux.User return nil } diff --git a/pkg/apic/apiserver/models/api/v1/owner_test.go b/pkg/apic/apiserver/models/api/v1/owner_test.go index dca755339..d6e314787 100644 --- a/pkg/apic/apiserver/models/api/v1/owner_test.go +++ b/pkg/apic/apiserver/models/api/v1/owner_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOwner_MarshalJSON(t *testing.T) { +func TestOwnerMarshalJSON(t *testing.T) { o := &Owner{} o.SetID("123") @@ -60,4 +60,47 @@ func TestOwner_MarshalJSON(t *testing.T) { assert.Equal(t, o3.Type, o4.Type) assert.Equal(t, o3.ID, o4.ID) assert.Equal(t, o3.Organization, o4.Organization) + +} + +func TestOwnerUserFieldMarshalJSON(t *testing.T) { + cases := map[string]struct { + user *OwnerUser + wantContains string + wantUser *OwnerUser + }{ + "user set — marshals and round-trips correctly": { + user: &OwnerUser{ID: "u-456"}, + wantContains: `"user"`, + wantUser: &OwnerUser{ID: "u-456"}, + }, + "user nil — omitted from JSON": { + user: nil, + wantContains: "", + wantUser: nil, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + o := &Owner{ID: "123", User: tc.user} + o.SetType(TeamOwner) + + b, err := o.MarshalJSON() + assert.Nil(t, err) + + if tc.wantContains != "" { + assert.Contains(t, string(b), tc.wantContains) + } else { + assert.NotContains(t, string(b), `"user"`) + } + + got := &Owner{} + err = json.Unmarshal(b, got) + assert.Nil(t, err) + assert.Equal(t, o.Type, got.Type) + assert.Equal(t, o.ID, got.ID) + assert.Equal(t, tc.wantUser, got.User) + }) + } } diff --git a/pkg/apic/apiserver/models/api/v1/resourcemeta.go b/pkg/apic/apiserver/models/api/v1/resourcemeta.go index c6f9d6de0..94d259c32 100644 --- a/pkg/apic/apiserver/models/api/v1/resourcemeta.go +++ b/pkg/apic/apiserver/models/api/v1/resourcemeta.go @@ -45,6 +45,8 @@ type ResourceMeta struct { Tags []string `json:"tags"` // Finalizer on the API server resource Finalizers []Finalizer `json:"finalizers"` + // Embedded contains the enriched references returned when fetching with ?embed=metadata.references. + Embedded map[string]EmbeddedReferences `json:"_embedded,omitempty"` // SubResources contains all of the unique sub resources that may be added to a resource SubResources map[string]interface{} `json:"-"` // Contains the name of the subResource mapped to its hash value @@ -214,6 +216,19 @@ func (rm *ResourceMeta) GetReferenceByGVK(gvk GroupVersionKind) Reference { return Reference{} } +// GetEmbeddedReferenceByGVK returns the first embedded reference that matches the GroupKind argument. +// Embedded references are only populated when the resource was fetched with ?embed=metadata.references. +func (rm *ResourceMeta) GetEmbeddedReferenceByGVK(gvk GroupVersionKind) EmbeddedReference { + for _, wrapper := range rm.Embedded { + for _, ref := range wrapper.References { + if ref.Group == gvk.Group && ref.Kind == gvk.Kind { + return ref + } + } + } + return EmbeddedReference{} +} + // GetReferenceByIDAndGVK returns the first found reference that matches the ID and GroupKind arguments. func (rm *ResourceMeta) GetReferenceByIDAndGVK(id string, gvk GroupVersionKind) Reference { for _, ref := range rm.Metadata.References { diff --git a/pkg/apic/apiserver/models/api/v1/resourcemeta_test.go b/pkg/apic/apiserver/models/api/v1/resourcemeta_test.go index 02126d771..9544f55e7 100644 --- a/pkg/apic/apiserver/models/api/v1/resourcemeta_test.go +++ b/pkg/apic/apiserver/models/api/v1/resourcemeta_test.go @@ -5,10 +5,17 @@ import ( "fmt" "testing" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/util" "github.com/stretchr/testify/assert" ) +const ( + testEmbedGroup = "catalog" + testEmbedKind = "PublishedProduct" + testEmbedRefID = "ref-abc-123" +) + func TestResourceMetaMarshal(t *testing.T) { apiID := "99" primaryKey := "4321" @@ -35,17 +42,17 @@ func TestResourceMetaMarshal(t *testing.T) { empty := meta1.GetSubResource("abc123") assert.Nil(t, empty) - meta1.SetSubResource("x-agent-details", map[string]interface{}{ + meta1.SetSubResource(defs.XAgentDetails, map[string]interface{}{ "apiID": apiID, }) // Get the sub resource by name, and update the value returned - resource := meta1.GetSubResource("x-agent-details") + resource := meta1.GetSubResource(defs.XAgentDetails) m := resource.(map[string]interface{}) m["primaryKey"] = primaryKey // save the resource with the new value - meta1.SetSubResource("x-agent-details", m) + meta1.SetSubResource(defs.XAgentDetails, m) bts, err := json.Marshal(meta1) assert.Nil(t, err) @@ -57,7 +64,7 @@ func TestResourceMetaMarshal(t *testing.T) { assert.Nil(t, err) // Get the x-agent-details sub resource, and convert it to a map - subResource := values["x-agent-details"] + subResource := values[defs.XAgentDetails] xAgentDetailsSub, ok := subResource.(map[string]interface{}) assert.True(t, ok) @@ -75,7 +82,7 @@ func TestResourceMetaMarshal(t *testing.T) { // expect to the sub resources to be equal assert.Equal(t, len(meta2.SubResources), len(meta1.SubResources)) - assert.Equal(t, xAgentDetailsSub, meta2.GetSubResource("x-agent-details")) + assert.Equal(t, xAgentDetailsSub, meta2.GetSubResource(defs.XAgentDetails)) // unset the name meta1.Name = "" @@ -287,55 +294,133 @@ func TestResourceMetaGetSelfLink(t *testing.T) { assert.Equal(t, "/selflink", link) } -func TestResourceMetaGetKindLink(t *testing.T) { +func TestGetKindLink(t *testing.T) { + const kindOnlyPath = "/group/v1/kinds" plurals["kind"] = "kinds" plurals["scopeKind"] = "scopeKinds" - // nil receiver - var nilMeta *ResourceMeta - assert.Equal(t, "", nilMeta.GetKindLink()) - - meta := &ResourceMeta{ - GroupVersionKind: GroupVersionKind{ - GroupKind: GroupKind{ - Group: "group", - Kind: "kind", + tests := map[string]struct { + setup func() *ResourceMeta + expected string + }{ + "nil receiver returns empty": { + setup: func() *ResourceMeta { return nil }, + expected: "", + }, + "missing APIVersion returns empty": { + setup: func() *ResourceMeta { + return &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}}, + } }, + expected: "", }, - Title: "title", - Metadata: Metadata{ - ID: "333", + "no scope returns kind-only path": { + setup: func() *ResourceMeta { + return &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}, APIVersion: "v1"}, + } + }, + expected: kindOnlyPath, + }, + "scope kind set, empty scope name omits scope path": { + setup: func() *ResourceMeta { + return &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}, APIVersion: "v1"}, + Metadata: Metadata{Scope: MetadataScope{Kind: "scopeKind"}}, + } + }, + expected: kindOnlyPath, + }, + "scope kind set, valid scope name includes scope path": { + setup: func() *ResourceMeta { + return &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}, APIVersion: "v1"}, + Metadata: Metadata{Scope: MetadataScope{Kind: "scopeKind", Name: "scope"}}, + } + }, + expected: "/group/v1/scopeKinds/scope/kinds", + }, + "no scope kind, scopeKindMap lookup includes scope path": { + setup: func() *ResourceMeta { + m := &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}, APIVersion: "v1"}, + Metadata: Metadata{Scope: MetadataScope{Name: "scope"}}, + } + scopeKindMap[m.GroupKind] = "scopeKind" + return m + }, + expected: "/group/v1/scopeKinds/scope/kinds", + }, + "no scope kind, scopeKindMap has no entry omits scope path": { + setup: func() *ResourceMeta { + return &ResourceMeta{ + GroupVersionKind: GroupVersionKind{GroupKind: GroupKind{Group: "group", Kind: "kind"}, APIVersion: "v1"}, + Metadata: Metadata{Scope: MetadataScope{Name: "scope"}}, + } + }, + expected: kindOnlyPath, }, - Name: "name", } - // missing version - assert.Equal(t, "", meta.GetKindLink()) - - meta.APIVersion = "v1" - - // no scope - assert.Equal(t, "/group/v1/kinds", meta.GetKindLink()) - - // scope kind set, no scope name → scope path omitted - meta.Metadata.Scope.Kind = "scopeKind" - assert.Equal(t, "/group/v1/kinds", meta.GetKindLink()) + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + meta := tc.setup() + if meta != nil { + t.Cleanup(func() { delete(scopeKindMap, meta.GroupKind) }) + } + assert.Equal(t, tc.expected, meta.GetKindLink()) + }) + } +} - // scope kind set, wildcard scope name → scope path omitted - meta.Metadata.Scope.Name = "*" - assert.Equal(t, "/group/v1/kinds", meta.GetKindLink()) +func TestGetEmbeddedReferenceByGVK(t *testing.T) { + matchGVK := GroupVersionKind{GroupKind: GroupKind{Group: testEmbedGroup, Kind: testEmbedKind}} + otherGVK := GroupVersionKind{GroupKind: GroupKind{Group: "management", Kind: "APIService"}} - // scope kind set, valid scope name → scope path included - meta.Metadata.Scope.Name = "scope" - assert.Equal(t, "/group/v1/scopeKinds/scope/kinds", meta.GetKindLink()) + matchRef := EmbeddedReference{ + Group: testEmbedGroup, + Kind: testEmbedKind, + Name: "my-product", + Metadata: EmbeddedReferenceMetadata{ID: testEmbedRefID}, + } - // no scope kind, scopeKindMap lookup finds scope → scope path included - meta.Metadata.Scope.Kind = "" - meta.Metadata.Scope.Name = "scope" - scopeKindMap[meta.GroupKind] = "scopeKind" - assert.Equal(t, "/group/v1/scopeKinds/scope/kinds", meta.GetKindLink()) + cases := map[string]struct { + embedded map[string]EmbeddedReferences + gvk GroupVersionKind + wantID string + }{ + "nil embedded map returns zero value": { + embedded: nil, + gvk: matchGVK, + wantID: "", + }, + "empty embedded map returns zero value": { + embedded: map[string]EmbeddedReferences{}, + gvk: matchGVK, + wantID: "", + }, + "non-matching GVK returns zero value": { + embedded: map[string]EmbeddedReferences{ + "metadata": {References: []EmbeddedReference{matchRef}}, + }, + gvk: otherGVK, + wantID: "", + }, + "matching GVK returns reference": { + embedded: map[string]EmbeddedReferences{ + "metadata": {References: []EmbeddedReference{matchRef}}, + }, + gvk: matchGVK, + wantID: testEmbedRefID, + }, + } - // no scope kind, scopeKindMap has no entry → scope path omitted - delete(scopeKindMap, meta.GroupKind) - assert.Equal(t, "/group/v1/kinds", meta.GetKindLink()) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + meta := &ResourceMeta{Embedded: tc.embedded} + got := meta.GetEmbeddedReferenceByGVK(tc.gvk) + assert.Equal(t, tc.wantID, got.Metadata.ID) + }) + } } diff --git a/pkg/apic/apiserver/models/api/v1/types.go b/pkg/apic/apiserver/models/api/v1/types.go index 90b26ee04..edde0bad2 100644 --- a/pkg/apic/apiserver/models/api/v1/types.go +++ b/pkg/apic/apiserver/models/api/v1/types.go @@ -39,6 +39,29 @@ type MetadataScope struct { SelfLink string `json:"selfLink,omitempty"` } +// EmbeddedReferenceMetadata is the metadata sub-object on an EmbeddedReference. +type EmbeddedReferenceMetadata struct { + ID string `json:"id,omitempty"` +} + +// EmbeddedReferences is the wrapper object under each key in the _embedded map. +type EmbeddedReferences struct { + References []EmbeddedReference `json:"references,omitempty"` +} + +// EmbeddedReference is the richer reference type returned only when fetching a resource +// with ?embed=metadata.references. It carries the referenced resource's owner inline, +// whereas the flat Reference type (used by GetReferenceByGVK) does not. +type EmbeddedReference struct { + Group string `json:"group,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + Metadata EmbeddedReferenceMetadata `json:"metadata,omitempty"` + Owner *Owner `json:"owner,omitempty"` +} + // Reference List of objects dependent by this object. type Reference struct { // Unique id generated by the server. diff --git a/pkg/apic/apiservicerevision.go b/pkg/apic/apiservicerevision.go index d29640cea..725e383a6 100644 --- a/pkg/apic/apiservicerevision.go +++ b/pkg/apic/apiservicerevision.go @@ -13,12 +13,10 @@ import ( "text/template" "time" - "github.com/Axway/agent-sdk/pkg/util" - coreapi "github.com/Axway/agent-sdk/pkg/api" - utilerrors "github.com/Axway/agent-sdk/pkg/util/errors" - management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/util" + utilerrors "github.com/Axway/agent-sdk/pkg/util/errors" "github.com/Axway/agent-sdk/pkg/util/log" ) diff --git a/pkg/apic/resourcePagination.go b/pkg/apic/resourcePagination.go index 385787957..9ec84d1dc 100644 --- a/pkg/apic/resourcePagination.go +++ b/pkg/apic/resourcePagination.go @@ -153,7 +153,9 @@ func (c *ServiceClient) GetAPIV1ResourceInstancesWithPageSize(queryParams map[st } resourceInstancePage := make([]*apiv1.ResourceInstance, 0) - json.Unmarshal(response, &resourceInstancePage) + if err := json.Unmarshal(response, &resourceInstancePage); err != nil { + log.WithError(err).Debug("error deserializing resource page response") + } resourceInstance = append(resourceInstance, resourceInstancePage...) diff --git a/pkg/traceability/httpclient.go b/pkg/traceability/httpclient.go index f0eec4236..342232cb4 100644 --- a/pkg/traceability/httpclient.go +++ b/pkg/traceability/httpclient.go @@ -178,13 +178,13 @@ func (client *HTTPClient) publishEvents(data []publisher.Event) error { timeStamp = event.Content.Timestamp allFields, err := event.Content.Fields.GetValue("fields") if err != nil { - client.headers[FlowHeader] = TransactionFlow + delete(client.headers, FlowHeader) continue } - if flow, ok := allFields.(map[string]interface{})[FlowHeader]; !ok { - client.headers[FlowHeader] = TransactionFlow - } else { + if flow, ok := allFields.(map[string]interface{})[FlowHeader]; ok { client.headers[FlowHeader] = flow.(string) + } else { + delete(client.headers, FlowHeader) } } } diff --git a/pkg/traceability/httpclient_test.go b/pkg/traceability/httpclient_test.go new file mode 100644 index 000000000..43582e239 --- /dev/null +++ b/pkg/traceability/httpclient_test.go @@ -0,0 +1,88 @@ +package traceability + +import ( + "net/http" + "testing" + "time" + + "github.com/elastic/beats/v7/libbeat/beat" + "github.com/elastic/beats/v7/libbeat/common" + "github.com/elastic/beats/v7/libbeat/publisher" + "github.com/stretchr/testify/assert" + + "github.com/Axway/agent-sdk/pkg/agent" + "github.com/Axway/agent-sdk/pkg/api" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +const metricFlowValue = "api-central-metric" + +func makeFlowEvent(flow string) publisher.Event { + fields := common.MapStr{ + "message": `{"event":"test"}`, + } + if flow != "" { + fields["fields"] = map[string]interface{}{ + FlowHeader: flow, + } + } + return publisher.Event{ + Content: beat.Event{ + Timestamp: time.Now(), + Fields: fields, + }, + } +} + +// TestPublishEventsFlowHeader verifies that publishEvents correctly manages the +// axway-target-flow header in client.headers for metric and transaction batches. +func TestPublishEventsFlowHeader(t *testing.T) { + cases := map[string]struct { + preSeedHeaders map[string]string // simulate headers left by a prior batch + events []publisher.Event + wantFlowHeader string // "" means key must be absent from client.headers + }{ + "metric event sets flow header": { + events: []publisher.Event{makeFlowEvent(metricFlowValue)}, + wantFlowHeader: metricFlowValue, + }, + "transaction event does not add flow header": { + events: []publisher.Event{makeFlowEvent("")}, + wantFlowHeader: "", + }, + "stale metric flow header cleared by subsequent transaction batch": { + preSeedHeaders: map[string]string{FlowHeader: metricFlowValue}, + events: []publisher.Event{makeFlowEvent("")}, + wantFlowHeader: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + agent.InitializeForTest(nil) + + headers := make(map[string]string) + for k, v := range tc.preSeedHeaders { + headers[k] = v + } + + client := &HTTPClient{ + Connection: Connection{ + connected: true, + encoder: newJSONEncoder(nil), + api: &api.MockHTTPClient{ResponseCode: http.StatusOK}, + }, + headers: headers, + logger: log.NewFieldLogger(), + } + + client.publishEvents(tc.events) //nolint:errcheck + + if tc.wantFlowHeader == "" { + assert.NotContains(t, client.headers, FlowHeader) + } else { + assert.Equal(t, tc.wantFlowHeader, client.headers[FlowHeader]) + } + }) + } +} diff --git a/pkg/transaction/contract_test.go b/pkg/transaction/contract_test.go new file mode 100644 index 000000000..9113f213a --- /dev/null +++ b/pkg/transaction/contract_test.go @@ -0,0 +1,207 @@ +package transaction + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Axway/agent-sdk/pkg/transaction/models" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +const ( + contractSDKVersion = "1.0.0-sdk" + contractAgentName = "test-agent" + contractLegTxnID = "contract-leg-1" + contractAPICDeploy = "prod-deploy" + contractOrg = "contract-org" + contractEnv = "contract-env" +) + +// TestContractTransactionV2Data validates the JSON shape of both api.transaction.event and +// api.transaction.summary envelopes against the schema contract. +func TestContractTransactionV2Data(t *testing.T) { + reporter := ReporterInfo{ + AgentVersion: "1.0.0", + AgentType: "TestAgent", + AgentSDKVersion: contractSDKVersion, + AgentName: contractAgentName, + } + + cases := map[string]struct { + logEvent LogEvent + orgID string + envID string + check func(t *testing.T, ie *InsightsEvent, raw string) + }{ + "leg event v2 has correct envelope and data shape": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: contractLegTxnID, + APICDeployment: contractAPICDeploy, + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Duration: 340, + Direction: "Inbound", + }, + }, + orgID: contractOrg, + envID: contractEnv, + check: func(t *testing.T, ie *InsightsEvent, raw string) { + assert.Equal(t, insightsEventVersion, ie.Version) + assert.Equal(t, "api.transaction.event", ie.Event) + assert.Equal(t, contractOrg, ie.Org) + assert.NotEmpty(t, ie.ID) + require.NotNil(t, ie.Distribution) + assert.Equal(t, contractEnv, ie.Distribution.Environment) + require.NotNil(t, ie.Session) + assert.Equal(t, contractLegTxnID, ie.Session.ID) + + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok, "data must be *TransactionLegData") + assert.Equal(t, "2", data.Version) + assert.Equal(t, contractAPICDeploy, data.APICDeployment) + assert.Equal(t, contractLegTxnID, data.TransactionID) + assert.Equal(t, 0, data.LegID) + + assert.Contains(t, raw, `"api.transaction.event"`) + assert.Contains(t, raw, `"version":"4"`) + assert.Contains(t, raw, `"version":"2"`) + assert.NotContains(t, raw, `"isInMetricEvent"`) + assert.NotContains(t, raw, `"team":{`) + assert.NotContains(t, raw, `"apiServiceInstance"`) + assert.NotContains(t, raw, `"statusDetail"`) + assert.NotContains(t, raw, `"entryPoint"`) + assert.NotContains(t, raw, `"assetResource"`) + assert.NotContains(t, raw, `"apiServiceRevision"`) + }, + }, + "summary event v2 has correct envelope and data shape": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "contract-sum-1", + APICDeployment: contractAPICDeploy, + TransactionSummary: &Summary{ + Status: "Success", + StatusDetail: "200", + Duration: 340, + OwnerInfo: &models.Owner{Type: "team", TeamGUID: "team-contract"}, + EntryPoint: &EntryPoint{ + Method: "GET", + Path: "/pets/123", + Host: "api.example.com", + }, + ConsumerDetails: &models.ConsumerDetails{ + Marketplace: &models.MarketplaceReference{ + GUID: "mp-guid-contract", + ConsumerOrgID: "consumer-org-contract", + }, + }, + AppOwnerInfo: &models.Owner{Type: "team", TeamGUID: "app-team-contract"}, + }, + }, + orgID: contractOrg, + envID: contractEnv, + check: func(t *testing.T, ie *InsightsEvent, raw string) { + assert.Equal(t, insightsEventVersion, ie.Version) + assert.Equal(t, "api.transaction.summary", ie.Event) + assert.Equal(t, contractOrg, ie.Org) + assert.NotEmpty(t, ie.ID) + require.NotNil(t, ie.Session) + assert.Equal(t, "contract-sum-1", ie.Session.ID) + + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok, "data must be *TransactionSummaryData") + assert.Equal(t, "2", data.Version) + assert.Equal(t, contractAPICDeploy, data.APICDeployment) + assert.Equal(t, "Success", data.Status) + assert.Equal(t, "200", data.StatusDetail) + assert.Equal(t, 340, data.Duration) + + require.NotNil(t, data.API) + require.NotNil(t, data.API.Owner) + assert.Equal(t, "team", data.API.Owner.Type) + assert.Equal(t, "team-contract", data.API.Owner.TeamGUID) + + require.NotNil(t, data.EntryPoint) + assert.Equal(t, "GET", data.EntryPoint.Method) + assert.Equal(t, "/pets/123", data.EntryPoint.Path) + assert.Equal(t, "api.example.com", data.EntryPoint.Host) + + require.NotNil(t, data.ConsumerDetails) + assert.Equal(t, "consumer-org-contract", data.ConsumerDetails.ConsumerOrgID) + require.NotNil(t, data.ConsumerDetails.Marketplace) + assert.Equal(t, "mp-guid-contract", data.ConsumerDetails.Marketplace.GUID) + + require.NotNil(t, data.ConsumerDetails.Application) + require.NotNil(t, data.ConsumerDetails.Application.Owner) + assert.Equal(t, "team", data.ConsumerDetails.Application.Owner.Type) + assert.Equal(t, "app-team-contract", data.ConsumerDetails.Application.Owner.TeamGUID) + + require.NotNil(t, data.Reporter) + assert.Equal(t, "1.0.0", data.Reporter.Version) + assert.Equal(t, "TestAgent", data.Reporter.Type) + assert.Equal(t, contractSDKVersion, data.Reporter.AgentSDKVersion) + assert.Equal(t, contractAgentName, data.Reporter.AgentName) + + assert.Contains(t, raw, `"api.transaction.summary"`) + assert.Contains(t, raw, `"version":"4"`) + assert.Contains(t, raw, `"version":"2"`) + assert.NotContains(t, raw, `"isInMetricEvent"`) + assert.NotContains(t, raw, `"legId"`) + assert.NotContains(t, raw, `"direction"`) + assert.NotContains(t, raw, `"uri"`) + assert.NotContains(t, raw, `"application.id"`) + assert.NotContains(t, raw, `"api.revision"`) + assert.NotContains(t, raw, `"api.teamId"`) + assert.NotContains(t, raw, `"proxy.apiServiceInstance"`) + assert.NotContains(t, raw, `"proxy.revision"`) + }, + }, + "deprecated proxy fields present when proxy is set": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "contract-dep-1", + TransactionSummary: &Summary{ + Status: "Success", + Proxy: &Proxy{ID: "proxy-deprecated-id", Name: "proxy-deprecated-name"}, + }, + }, + orgID: "org-dep", + envID: "env-dep", + check: func(t *testing.T, ie *InsightsEvent, raw string) { + assert.Contains(t, raw, "proxy-deprecated-id") + assert.Contains(t, raw, "proxy-deprecated-name") + }, + }, + "deprecated proxy fields absent via omitempty when proxy is nil": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "contract-dep-2", + TransactionSummary: &Summary{Status: "Success"}, + }, + orgID: "org-dep", + envID: "env-dep", + check: func(t *testing.T, ie *InsightsEvent, raw string) { + assert.NotContains(t, raw, `"proxy.id"`) + assert.NotContains(t, raw, `"proxy.name"`) + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), tc.logEvent, tc.orgID, tc.envID, nil, nil, reporter) + require.NoError(t, err) + require.NotNil(t, ie) + + b, err := json.Marshal(ie) + require.NoError(t, err) + + tc.check(t, ie, string(b)) + }) + } +} diff --git a/pkg/transaction/definitions.go b/pkg/transaction/definitions.go index 87bb35003..04b845bbd 100644 --- a/pkg/transaction/definitions.go +++ b/pkg/transaction/definitions.go @@ -86,6 +86,8 @@ type Summary struct { ProductPlan *models.ProductPlan `json:"productPlan,omitempty"` Quota *models.Quota `json:"quota,omitempty"` ConsumerDetails *models.ConsumerDetails `json:"consumerDetails,omitempty"` + OwnerInfo *models.Owner `json:"-"` + AppOwnerInfo *models.Owner `json:"-"` } // Application - Represents the application used in transaction summary event (dataplane) @@ -135,6 +137,8 @@ type Event struct { Direction string `json:"direction,omitempty"` Status string `json:"status,omitempty"` Protocol TransportProtocol `json:"protocol,omitempty"` + ProxyName string `json:"proxyName,omitempty"` + ProxyID string `json:"proxyId,omitempty"` } // Protocol - Represents the protocol details in transaction detail events diff --git a/pkg/transaction/eventgenerator.go b/pkg/transaction/eventgenerator.go index 9887a07c7..b4bf5a257 100644 --- a/pkg/transaction/eventgenerator.go +++ b/pkg/transaction/eventgenerator.go @@ -2,15 +2,20 @@ package transaction import ( "encoding/json" + "fmt" "strings" "time" + "github.com/elastic/beats/v7/libbeat/beat" + "github.com/elastic/beats/v7/libbeat/common" + "github.com/Axway/agent-sdk/pkg/agent" "github.com/Axway/agent-sdk/pkg/agent/cache" "github.com/Axway/agent-sdk/pkg/apic" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" catalog "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/catalog/v1alpha1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/cmd" "github.com/Axway/agent-sdk/pkg/traceability" "github.com/Axway/agent-sdk/pkg/traceability/sampling" "github.com/Axway/agent-sdk/pkg/transaction/metric" @@ -19,8 +24,6 @@ import ( sdkErrors "github.com/Axway/agent-sdk/pkg/util/errors" hc "github.com/Axway/agent-sdk/pkg/util/healthcheck" "github.com/Axway/agent-sdk/pkg/util/log" - "github.com/elastic/beats/v7/libbeat/beat" - "github.com/elastic/beats/v7/libbeat/common" ) // EventGenerator - Create the events to be published to Condor @@ -112,7 +115,7 @@ func (e *Generator) CreateFromEventReport(eventReport EventReport) ([]beat.Event //if no summary is sent then prepare the array of TransactionEvents for publishing if eventReport.GetSummaryEvent() == (LogEvent{}) { - return e.handleTransactionEvents(eventReport.GetDetailEvents(), eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) + return e.handleTransactionEvents(eventReport.GetDetailEvents(), nil, eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) } // Check to see if marketplace provisioning/subs is enabled @@ -131,13 +134,17 @@ func (e *Generator) CreateFromEventReport(eventReport EventReport) ([]beat.Event metadata = SetSampleInMetadata(metadata) } - newEvent, err := e.createEvent(newSummaryEvent, eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) + newEvent, err := e.createEvent(newSummaryEvent, nil, eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) if err != nil { logger.WithError(err).Trace("handling summary event") return events, err } - detailEvents, err := e.handleTransactionEvents(eventReport.GetDetailEvents(), eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) + var summaryProxy *Proxy + if s := newSummaryEvent.TransactionSummary; s != nil { + summaryProxy = s.Proxy + } + detailEvents, err := e.handleTransactionEvents(eventReport.GetDetailEvents(), summaryProxy, eventReport.GetEventTime(), metadata, eventReport.GetFields(), eventReport.GetPrivateData()) if err != nil { logger.WithError(err).Trace("handling detail event(s)") return events, err @@ -214,15 +221,61 @@ func (e *Generator) trackMetrics(summaryEvent LogEvent, bytes int64) { } // CreateEvent - Creates a new event to be sent to Amplify Observability -func (e *Generator) createEvent(logEvent LogEvent, eventTime time.Time, metaData common.MapStr, eventFields common.MapStr, privateData interface{}) (beat.Event, error) { +func (e *Generator) createEvent(logEvent LogEvent, summaryProxy *Proxy, eventTime time.Time, metaData common.MapStr, eventFields common.MapStr, privateData interface{}) (beat.Event, error) { event := beat.Event{} - serializedLogEvent, err := json.Marshal(logEvent) + + e.logger. + WithField("transactionID", logEvent.TransactionID). + WithField("eventType", logEvent.Type). + Debug("building insights transaction event") + + cfg := agent.GetCentralConfig() + if cfg == nil { + return event, fmt.Errorf("central config unavailable; cannot construct insights event for type %q", logEvent.Type) + } + + orgID := metric.GetOrgGUID() + if orgID == "" { + orgID = cfg.GetTenantID() + } + envID := cfg.GetEnvironmentID() + if orgID == "" { + return event, fmt.Errorf("required field \"org\" (tenantID) is empty for insights event type %q", logEvent.Type) + } + if envID == "" { + return event, fmt.Errorf("required field \"distribution.environment\" (environmentID) is empty for insights event type %q", logEvent.Type) + } + + e.logger. + WithField("orgID", orgID). + WithField("envID", envID). + WithField("apicDeployment", cfg.GetAPICDeployment()). + Trace("insights event parameters") + + reporter := ReporterInfo{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: cfg.GetAgentName(), + } + + insightsEvent, err := BuildTransactionV2Data(e.logger, logEvent, orgID, envID, summaryProxy, agent.GetCacheManager(), reporter) + if err != nil { + return event, fmt.Errorf("failed to build insights event for type %q: %w", logEvent.Type, err) + } + + e.logger. + WithField("transactionID", logEvent.TransactionID). + WithField("eventType", logEvent.Type). + WithField("envelopeVersion", insightsEvent.Version). + Info("insights transaction event built") + + serialized, err := json.Marshal(insightsEvent) if err != nil { return event, err } - // No need to get the other field data if not being sampled - eventData, err := e.createEventData(serializedLogEvent, eventFields) + eventData, err := e.createEventData(serialized, eventFields) if err != nil { return event, err } @@ -245,14 +298,14 @@ func (e *Generator) getBytesSent(detailEvents []LogEvent) int { return 0 } -func (e *Generator) handleTransactionEvents(detailEvents []LogEvent, eventTime time.Time, metaData common.MapStr, eventFields common.MapStr, privateData interface{}) ([]beat.Event, error) { +func (e *Generator) handleTransactionEvents(detailEvents []LogEvent, summaryProxy *Proxy, eventTime time.Time, metaData common.MapStr, eventFields common.MapStr, privateData interface{}) ([]beat.Event, error) { events := make([]beat.Event, 0) for _, event := range detailEvents { if metaData == nil { metaData = common.MapStr{} } metaData.Put(sampling.SampleKey, true) - newEvent, err := e.createEvent(event, eventTime, metaData, eventFields, privateData) + newEvent, err := e.createEvent(event, summaryProxy, eventTime, metaData, eventFields, privateData) if err != nil { return nil, err } @@ -299,6 +352,11 @@ func (e *Generator) updateTxnSummaryByAccessRequest(summaryEvent LogEvent) *Summ // Update the consumer details summaryEvent.TransactionSummary.ConsumerDetails = transutil.UpdateWithConsumerDetails(accessRequest, managedApp, e.logger) + summaryEvent.TransactionSummary.AppOwnerInfo = transutil.ResolveAppOwnerFromManagedApp(managedApp) + e.logger. + WithField("appOwnerType", summaryEvent.TransactionSummary.AppOwnerInfo.Type). + Trace("resolved app owner for summary event") + // Update provider details updatedSummaryEvent := updateWithProviderDetails(accessRequest, managedApp, summaryEvent.TransactionSummary, e.logger) @@ -452,7 +510,6 @@ func (e *Generator) createEventFields() (fields map[string]string, err error) { return } fields["token"] = token - fields[traceability.FlowHeader] = traceability.TransactionFlow return } @@ -466,33 +523,30 @@ func SetSampleInMetadata(metadata common.MapStr) common.MapStr { // updateWithProviderDetails - func updateWithProviderDetails(accessRequest *management.AccessRequest, managedApp *v1.ResourceInstance, summaryEvent *Summary, log log.FieldLogger) *Summary { - - // Set default to provider details in case access request or managed apps comes back nil - summaryEvent.AssetResource = &models.AssetResource{ - ID: unknown, - Name: unknown, - } - - summaryEvent.Product = &models.Product{ - ID: unknown, - Name: unknown, - VersionID: unknown, - VersionName: unknown, - } - - summaryEvent.ProductPlan = &models.ProductPlan{ - ID: unknown, - } - - summaryEvent.Quota = &models.Quota{ - ID: unknown, - } + setProviderDefaults(summaryEvent) if accessRequest == nil || managedApp == nil { log.Trace("access request or managed app is nil. Setting default values to unknown") return summaryEvent } + setProductDetails(accessRequest, summaryEvent, log) + setAssetResourceDetails(accessRequest, summaryEvent, log) + setAPIDetails(accessRequest, summaryEvent, log) + setProductPlanDetails(accessRequest, summaryEvent, log) + setQuotaDetails(accessRequest, summaryEvent, log) + + return summaryEvent +} + +func setProviderDefaults(summaryEvent *Summary) { + summaryEvent.AssetResource = &models.AssetResource{ID: unknown, Name: unknown} + summaryEvent.Product = &models.Product{ID: unknown, Name: unknown, VersionID: unknown, VersionName: unknown} + summaryEvent.ProductPlan = &models.ProductPlan{ID: unknown} + summaryEvent.Quota = &models.Quota{ID: unknown} +} + +func setProductDetails(accessRequest *management.AccessRequest, summaryEvent *Summary, log log.FieldLogger) { productRef := accessRequest.GetReferenceByGVK(catalog.ProductGVK()) if productRef.ID == "" || productRef.Name == "" { log.Trace("could not get product information, setting product to unknown") @@ -508,33 +562,46 @@ func updateWithProviderDetails(accessRequest *management.AccessRequest, managedA summaryEvent.Product.VersionID = productReleaseRef.ID summaryEvent.Product.VersionName = productReleaseRef.Name } + + summaryEvent.Product.Owner = transutil.ResolveProductOwner(accessRequest.GetEmbeddedReferenceByGVK(catalog.PublishedProductGVK())) log. WithField("productId", summaryEvent.Product.ID). WithField("productName", summaryEvent.Product.Name). WithField("productVersionId", summaryEvent.Product.VersionID). WithField("productVersionName", summaryEvent.Product.VersionName). Trace("product information") +} - assetResourceRef := accessRequest.GetReferenceByGVK(catalog.AssetResourceGVK()) - if assetResourceRef.ID == "" || assetResourceRef.Name == "" { +func setAssetResourceDetails(accessRequest *management.AccessRequest, summaryEvent *Summary, log log.FieldLogger) { + ref := accessRequest.GetReferenceByGVK(catalog.AssetResourceGVK()) + if ref.ID == "" || ref.Name == "" { log.Trace("could not get asset resource, setting asset resource to unknown") } else { - summaryEvent.AssetResource.ID = assetResourceRef.ID - summaryEvent.AssetResource.Name = assetResourceRef.Name + summaryEvent.AssetResource.ID = ref.ID + summaryEvent.AssetResource.Name = ref.Name } log. WithField("assetResourceId", summaryEvent.AssetResource.ID). WithField("assetResourceName", summaryEvent.AssetResource.Name). Trace("asset resource information") +} - api := &models.APIDetails{ +func setAPIDetails(accessRequest *management.AccessRequest, summaryEvent *Summary, log log.FieldLogger) { + if summaryEvent.Proxy == nil || summaryEvent.Team == nil { + log.Trace("proxy or team is nil, skipping api details population") + return + } + apiSvcInstID := accessRequest.GetReferenceByGVK(management.APIServiceInstanceGVK()).ID + if apiSvcInstID == "" { + apiSvcInstID = accessRequest.Spec.ApiServiceInstance + } + summaryEvent.API = &models.APIDetails{ ID: summaryEvent.Proxy.ID, Name: summaryEvent.Proxy.Name, Revision: summaryEvent.Proxy.Revision, TeamID: summaryEvent.Team.ID, - APIServiceInstance: accessRequest.Spec.ApiServiceInstance, + APIServiceInstance: apiSvcInstID, } - summaryEvent.API = api log. WithField("proxyId", summaryEvent.Proxy.ID). WithField("proxyName", summaryEvent.Proxy.Name). @@ -542,26 +609,24 @@ func updateWithProviderDetails(accessRequest *management.AccessRequest, managedA WithField("proxyTeamId", summaryEvent.Team.ID). WithField("apiService", accessRequest.Spec.ApiServiceInstance). Trace("api details information") +} - productPlanRef := accessRequest.GetReferenceByGVK(catalog.ProductPlanGVK()) - if productPlanRef.ID == "" { +func setProductPlanDetails(accessRequest *management.AccessRequest, summaryEvent *Summary, log log.FieldLogger) { + ref := accessRequest.GetReferenceByGVK(catalog.ProductPlanGVK()) + if ref.ID == "" { log.Debug("could not get product plan ID, setting product plan to unknown") } else { - summaryEvent.ProductPlan.ID = productPlanRef.ID + summaryEvent.ProductPlan.ID = ref.ID } - log. - WithField("productPlanId", summaryEvent.ProductPlan.ID). - Trace("product plan ID information") + log.WithField("productPlanId", summaryEvent.ProductPlan.ID).Trace("product plan ID information") +} - quotaRef := accessRequest.GetReferenceByGVK(catalog.QuotaGVK()) - if quotaRef.ID == "" { +func setQuotaDetails(accessRequest *management.AccessRequest, summaryEvent *Summary, log log.FieldLogger) { + ref := accessRequest.GetReferenceByGVK(catalog.QuotaGVK()) + if ref.ID == "" { log.Debug("could not get quota ID, setting quota to unknown") } else { - summaryEvent.Quota.ID = quotaRef.ID + summaryEvent.Quota.ID = ref.ID } - log. - WithField("quotaId", summaryEvent.Quota.ID). - Trace("quota ID information") - - return summaryEvent + log.WithField("quotaId", summaryEvent.Quota.ID).Trace("quota ID information") } diff --git a/pkg/transaction/eventgenerator_test.go b/pkg/transaction/eventgenerator_test.go index 6c3368bda..0a742c92f 100644 --- a/pkg/transaction/eventgenerator_test.go +++ b/pkg/transaction/eventgenerator_test.go @@ -8,12 +8,19 @@ import ( "testing" "time" + "github.com/elastic/beats/v7/libbeat/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/Axway/agent-sdk/pkg/agent" + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + catalog "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/catalog/v1alpha1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" corecfg "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/traceability" "github.com/Axway/agent-sdk/pkg/traceability/sampling" - "github.com/elastic/beats/v7/libbeat/common" - "github.com/stretchr/testify/assert" + "github.com/Axway/agent-sdk/pkg/transaction/models" + "github.com/Axway/agent-sdk/pkg/util/log" ) type Config struct { @@ -71,7 +78,6 @@ func TestCreateEventWithValidTokenRequest(t *testing.T) { defer s.Close() cfg := createMapperTestConfig(s.URL, "1111", "aaa", "env1", "1111") - // authCfg := cfg.Central.GetAuthConfig() err := agent.Initialize(cfg.Central) assert.Nil(t, err) @@ -80,6 +86,12 @@ func TestCreateEventWithValidTokenRequest(t *testing.T) { TenantID: cfg.Central.GetTenantID(), Environment: cfg.Central.GetAPICDeployment(), EnvironmentID: cfg.Central.GetEnvironmentID(), + Type: TypeTransactionEvent, + TransactionID: "txn-test-1", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + }, } eventFields := make(common.MapStr) eventFields["someKey.1"] = "someVal.1" @@ -89,21 +101,30 @@ func TestCreateEventWithValidTokenRequest(t *testing.T) { events, _ := eventGenerator.CreateEvents(LogEvent{}, []LogEvent{dummyLogEvent}, time.Now(), nil, eventFields, nil) assert.NotNil(t, events) event := events[0] - // Validate that existing fields are added to generated event + + // Forwarded fields must be present assert.Equal(t, "someVal.1", event.Fields["someKey.1"]) assert.Equal(t, "someVal.2", event.Fields["someKey.2"]) msg := fmt.Sprintf("%v", event.Fields["message"]) fields := event.Fields["fields"].(map[string]string) assert.NotNil(t, fields) - assert.NotNil(t, msg) - // Validate if message field from orgincal event fields is not included + + // Original "message" field must not leak through assert.NotEqual(t, "existingMessage", event.Fields["message"]) - var logEvent LogEvent - json.Unmarshal([]byte(msg), &logEvent) - assert.Equal(t, dummyLogEvent, logEvent) + + // Message must now be an InsightsEvent envelope (version "4"). + // Use map to avoid interface{} unmarshaling issues with the Data field. + var envelope map[string]interface{} + err = json.Unmarshal([]byte(msg), &envelope) + assert.Nil(t, err) + assert.Equal(t, insightsEventVersion, envelope["version"]) + assert.Equal(t, "api.transaction.event", envelope["event"]) + assert.Equal(t, cfg.Central.GetTenantID(), envelope["org"]) + assert.NotEmpty(t, envelope["id"]) + assert.Equal(t, "somevalue", fields["token"]) - assert.Equal(t, traceability.TransactionFlow, fields[traceability.FlowHeader]) + assert.NotContains(t, fields, traceability.FlowHeader) } func TestCreateEventWithInvalidTokenRequest(t *testing.T) { @@ -119,6 +140,12 @@ func TestCreateEventWithInvalidTokenRequest(t *testing.T) { TenantID: cfg.Central.GetTenantID(), Environment: cfg.Central.GetAPICDeployment(), EnvironmentID: cfg.Central.GetEnvironmentID(), + Type: TypeTransactionEvent, + TransactionID: "txn-invalid-token", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + }, } _, err := eventGenerator.CreateEvents(LogEvent{}, []LogEvent{dummyLogEvent}, time.Now(), nil, nil, nil) @@ -126,6 +153,204 @@ func TestCreateEventWithInvalidTokenRequest(t *testing.T) { assert.Equal(t, "bad response from AxwayId: 403 Forbidden", err.Error()) } +func TestCreateEvent(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Write([]byte(`{"access_token":"tok","expires_in":99999}`)) + })) + defer s.Close() + + const tenantID = "tenant-create-event" + const envID = "env-create-event" + + cases := map[string]struct { + tenantID string + envID string + logEvent LogEvent + wantErr string + wantEvent string + }{ + "missing environmentID returns error": { + tenantID: "tenant-123", + envID: "", + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-guard", + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + wantErr: "distribution.environment", + }, + "unknown logEvent type returns error from BuildTransactionV2Data": { + tenantID: tenantID, + envID: envID, + logEvent: LogEvent{ + Type: "unknown.type", + TransactionID: "txn-bad-type", + }, + wantErr: "unknown logEvent type", + }, + "leg event beat message is InsightsEvent JSON": { + tenantID: tenantID, + envID: envID, + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-beat-leg", + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + wantEvent: "api.transaction.event", + }, + "summary event beat message is InsightsEvent JSON": { + tenantID: tenantID, + envID: envID, + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "txn-beat-sum", + TransactionSummary: &Summary{Status: "Success"}, + }, + wantEvent: "api.transaction.summary", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + cfg := createMapperTestConfig(s.URL, tc.tenantID, "prod", "env-name", tc.envID) + err := agent.Initialize(cfg.Central) + assert.Nil(t, err) + + gen := &Generator{ + shouldAddFields: false, + shouldUseTrafficForAggregation: false, + logger: log.NewFieldLogger(), + } + + beatEvent, err := gen.createEvent(tc.logEvent, nil, time.Now(), nil, nil, nil) + if tc.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + assert.NoError(t, err) + msg, ok := beatEvent.Fields["message"] + assert.True(t, ok) + + var envelope map[string]interface{} + err = json.Unmarshal([]byte(msg.(string)), &envelope) + assert.NoError(t, err) + assert.Equal(t, insightsEventVersion, envelope["version"]) + assert.Equal(t, tc.wantEvent, envelope["event"]) + assert.Equal(t, tc.tenantID, envelope["org"]) + assert.NotEmpty(t, envelope["id"]) + }) + } +} + +func TestCreateEventGuardCases(t *testing.T) { + cases := map[string]struct { + setupCfg func() corecfg.CentralConfig + logEvent LogEvent + wantErr string + }{ + "missing tenantID returns error": { + setupCfg: func() corecfg.CentralConfig { + cfg := &corecfg.CentralConfiguration{ + EnvironmentID: "env-guard", + } + cfg.SetEnvironmentID("env-guard") + return cfg + }, + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-guard-tenant", + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + wantErr: "tenantID", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(tc.setupCfg())) + gen := &Generator{ + shouldAddFields: false, + shouldUseTrafficForAggregation: false, + logger: log.NewFieldLogger(), + } + _, err := gen.createEvent(tc.logEvent, nil, time.Now(), nil, nil, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestUpdateWithProviderDetailsProductOwner(t *testing.T) { + const teamID = "team-guid-123" + + makeAccessRequest := func(withEmbedded bool) *management.AccessRequest { + ar := &management.AccessRequest{ + ResourceMeta: v1.ResourceMeta{ + Metadata: v1.Metadata{ + References: []v1.Reference{ + {ID: "prod-id", Name: "prod-name", Group: catalog.ProductGVK().Group, Kind: catalog.ProductGVK().Kind}, + {ID: "rel-id", Name: "rel-name", Group: catalog.ProductReleaseGVK().Group, Kind: catalog.ProductReleaseGVK().Kind}, + }, + }, + }, + } + if withEmbedded { + ar.Embedded = map[string]v1.EmbeddedReferences{ + catalog.PublishedProductGVK().Kind: { + References: []v1.EmbeddedReference{ + { + Group: catalog.PublishedProductGVK().Group, + Kind: catalog.PublishedProductGVK().Kind, + Owner: &v1.Owner{Type: v1.TeamOwner, ID: teamID}, + }, + }, + }, + } + } + return ar + } + + tests := map[string]struct { + withEmbedded bool + wantOwnerType string + wantTeamGUID string + }{ + "product owner resolved from embedded reference": { + withEmbedded: true, + wantOwnerType: "team", + wantTeamGUID: teamID, + }, + "product owner is none when embedded reference absent": { + withEmbedded: false, + wantOwnerType: "none", + wantTeamGUID: "", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + summary := &Summary{ + Proxy: &Proxy{ID: "remoteApiId_test", Name: "test-api"}, + Team: &Team{ID: "team-id"}, + Product: &models.Product{}, + ProductPlan: &models.ProductPlan{}, + Quota: &models.Quota{}, + AssetResource: &models.AssetResource{}, + } + ar := makeAccessRequest(tc.withEmbedded) + manApp := &v1.ResourceInstance{} + result := updateWithProviderDetails(ar, manApp, summary, log.NewFieldLogger()) + require.NotNil(t, result) + require.NotNil(t, result.Product) + require.NotNil(t, result.Product.Owner) + assert.Equal(t, tc.wantOwnerType, result.Product.Owner.Type) + assert.Equal(t, tc.wantTeamGUID, result.Product.Owner.TeamGUID) + }) + } +} + func TestCreateEventsInOfflineMode(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { resp.WriteHeader(http.StatusForbidden) diff --git a/pkg/transaction/logeventbuilder.go b/pkg/transaction/logeventbuilder.go index 04c9c845c..1b4cbfdd8 100644 --- a/pkg/transaction/logeventbuilder.go +++ b/pkg/transaction/logeventbuilder.go @@ -53,6 +53,8 @@ type EventBuilder interface { SetStatus(status TxEventStatus) EventBuilder SetProtocolDetail(protocolDetail interface{}) EventBuilder SetRedactionConfig(config redaction.Redactions) EventBuilder + SetProxy(proxyID, proxyName string) EventBuilder + SetProxyWithStage(proxyID, proxyName, proxyStage string) EventBuilder Build() (*LogEvent, error) } @@ -84,6 +86,7 @@ type SummaryBuilder interface { SetProxy(proxyID, proxyName string, proxyRevision int) SummaryBuilder SetProxyWithStage(proxyID, proxyName, proxyStage string, proxyRevision int) SummaryBuilder SetProxyWithStageVersion(proxyID, proxyName, proxyStage, proxyVersion string, proxyRevision int) SummaryBuilder + SetAPIServiceRevision(id string) SummaryBuilder SetRunTime(runtimeID, runtimeName string) SummaryBuilder SetEntryPoint(entryPointType, method, path, host string) SummaryBuilder SetIsInMetricEvent(isInMetricEvent bool) SummaryBuilder @@ -252,6 +255,20 @@ func (b *transactionEventBuilder) SetDestination(destination string) EventBuilde return b } +func (b *transactionEventBuilder) SetProxy(proxyID, proxyName string) EventBuilder { + return b.SetProxyWithStage(proxyID, proxyName, "") +} + +func (b *transactionEventBuilder) SetProxyWithStage(proxyID, proxyName, proxyStage string) EventBuilder { + if b.err != nil { + return b + } + b.logEvent.TransactionEvent.Source = transutil.ResolveIDWithPrefix(proxyID, proxyName) + b.logEvent.TransactionEvent.ProxyID = transutil.ResolveIDWithPrefix(proxyID, proxyName) + b.logEvent.TransactionEvent.ProxyName = proxyName + return b +} + func (b *transactionEventBuilder) SetDuration(duration int) EventBuilder { if b.err != nil { return b @@ -539,6 +556,18 @@ func (b *transactionSummaryBuilder) SetProxyWithStageVersion(proxyID, proxyName, return b } + +func (b *transactionSummaryBuilder) SetAPIServiceRevision(id string) SummaryBuilder { + if b.err != nil { + return b + } + if b.logEvent.TransactionSummary.API == nil { + b.logEvent.TransactionSummary.API = &models.APIDetails{} + } + b.logEvent.TransactionSummary.API.APIServiceInstance = id + return b +} + func (b *transactionSummaryBuilder) SetRunTime(runtimeID, runtimeName string) SummaryBuilder { if b.err != nil { return b diff --git a/pkg/transaction/logeventbuilder_test.go b/pkg/transaction/logeventbuilder_test.go index 223c3f061..09ce35ff3 100644 --- a/pkg/transaction/logeventbuilder_test.go +++ b/pkg/transaction/logeventbuilder_test.go @@ -12,6 +12,12 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + testTargetPath = "/targetPath" + testResourcePath = "/resourcePath" + testHost = "somehost.com" +) + func TestTransactionEventBuilder(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { token := "{\"access_token\":\"somevalue\",\"expires_in\": 12235677}" @@ -50,16 +56,16 @@ func TestTransactionEventBuilder(t *testing.T) { httpProtocol, _ := createHTTPProtocol("/testuri", "GET", "{}", "{}", 200, 10, 10, redactionConfig) logEvent, err := NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). Build() assert.Nil(t, logEvent) assert.NotNil(t, err) assert.Equal(t, "id property not set in transaction event", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -69,8 +75,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Equal(t, "direction property not set in transaction event", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -81,8 +87,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Equal(t, "status property not set in transaction event", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -94,8 +100,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Equal(t, "invalid transaction event status", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -111,8 +117,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Equal(t, "protocol details not set in transaction event", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -130,8 +136,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Equal(t, "unsupported protocol type", err.Error()) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -148,8 +154,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.Nil(t, err) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTransactionID("11111"). SetTimestamp(timeStamp). SetID("1111"). @@ -188,8 +194,8 @@ func TestTransactionEventBuilder(t *testing.T) { assert.True(t, ok) logEvent, err = NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTenantID("2222"). SetTrcbltPartitionID("2222"). SetEnvironmentName("env2"). @@ -252,15 +258,15 @@ func TestSummaryBuilder(t *testing.T) { logEvent, err := NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). SetTransactionID("11111"). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTimestamp(timeStamp). SetStatus(TxSummaryStatusSuccess, "200"). SetDuration(10). SetApplication("1111", "TestApp"). SetTeam("1111"). SetProxy("", "proxy", 1). - SetEntryPoint("http", "GET", "/test", "somehost.com"). + SetEntryPoint("http", "GET", "/test", testHost). SetIsInMetricEvent(true). Build() @@ -298,13 +304,13 @@ func TestSummaryBuilder(t *testing.T) { assert.Equal(t, "http", logEvent.TransactionSummary.EntryPoint.Type) assert.Equal(t, "GET", logEvent.TransactionSummary.EntryPoint.Method) assert.Equal(t, "/{*}", logEvent.TransactionSummary.EntryPoint.Path, "Path was not redacted as it should have been") - assert.Equal(t, "somehost.com", logEvent.TransactionSummary.EntryPoint.Host) + assert.Equal(t, testHost, logEvent.TransactionSummary.EntryPoint.Host) assert.Equal(t, true, logEvent.TransactionSummary.IsInMetricEvent) logEvent, err = NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetDuration(10). Build() assert.Nil(t, logEvent) @@ -313,9 +319,9 @@ func TestSummaryBuilder(t *testing.T) { logEvent, err = NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). - SetEntryPoint("http", "GET", "/test", "somehost.com"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). + SetEntryPoint("http", "GET", "/test", testHost). SetDuration(10). Build() assert.Nil(t, logEvent) @@ -324,9 +330,9 @@ func TestSummaryBuilder(t *testing.T) { logEvent, err = NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). - SetEntryPoint("http", "GET", "/test", "somehost.com"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). + SetEntryPoint("http", "GET", "/test", testHost). SetDuration(10). SetStatus("Pass", "200"). Build() @@ -337,9 +343,9 @@ func TestSummaryBuilder(t *testing.T) { // Test with explicitly setting properties that are set thru agent config by default logEvent, err = NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). - SetEntryPoint("http", "GET", "/test", "somehost.com"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). + SetEntryPoint("http", "GET", "/test", testHost). SetTenantID("2222"). SetTrcbltPartitionID("2222"). SetEnvironmentName("env2"). @@ -387,7 +393,7 @@ func TestSummaryBuilder(t *testing.T) { assert.Equal(t, "http", logEvent.TransactionSummary.EntryPoint.Type) assert.Equal(t, "GET", logEvent.TransactionSummary.EntryPoint.Method) assert.Equal(t, "/{*}", logEvent.TransactionSummary.EntryPoint.Path, "Path was not redacted as it should have been") - assert.Equal(t, "somehost.com", logEvent.TransactionSummary.EntryPoint.Host) + assert.Equal(t, testHost, logEvent.TransactionSummary.EntryPoint.Host) assert.Equal(t, false, logEvent.TransactionSummary.IsInMetricEvent) } @@ -400,8 +406,8 @@ func TestLogRedactionOverride(t *testing.T) { httpProtocol, _ := createHTTPProtocol("/testuri", "GET", "{}", "{}", 200, 10, 10, redactionConfig) logEvent, err := NewTransactionEventBuilder(). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). SetTenantID("2222"). SetTrcbltPartitionID("2222"). SetEnvironmentName("env2"). @@ -428,9 +434,9 @@ func TestLogRedactionOverride(t *testing.T) { logEvent, err = NewTransactionSummaryBuilder(). SetRedactionConfig(redactionConfig). - SetTargetPath("/targetPath"). - SetResourcePath("/resourcePath"). - SetEntryPoint("http", "GET", "/test", "somehost.com"). + SetTargetPath(testTargetPath). + SetResourcePath(testResourcePath). + SetEntryPoint("http", "GET", "/test", testHost). SetTenantID("2222"). SetTrcbltPartitionID("2222"). SetEnvironmentName("env2"). @@ -454,95 +460,149 @@ func TestLogRedactionOverride(t *testing.T) { assert.False(t, redactionConfig.jmsPropertiesRedactionCalled) } -func TestTransactionSummaryBuilder_SetProxyWithStageVersion_UnknownAPIID(t *testing.T) { - builder := NewTransactionSummaryBuilder() - builder.SetProxyWithStageVersion("", "", "stage", "version", 1) +func TestEventBuilderSetProxy(t *testing.T) { + const prefixedCatFactAPI = "remoteApiId_cat-fact-api" + cases := map[string]struct { + proxyID string + proxyName string + expectedSource string + }{ + "already-prefixed ID is preserved": { + proxyID: prefixedCatFactAPI, + proxyName: "Cat Fact API", + expectedSource: prefixedCatFactAPI, + }, + "empty proxyID falls back to proxyName with prefix": { + proxyID: "", + proxyName: "fallback-api", + expectedSource: "remoteApiId_fallback-api", + }, + "both empty produces unknown with prefix": { + proxyID: "", + proxyName: "", + expectedSource: "remoteApiId_unknown", + }, + "only-prefix ID falls back to proxyName": { + proxyID: "remoteApiId_", + proxyName: "fallback-api", + expectedSource: "remoteApiId_fallback-api", + }, + } - logEvent := builder.(*transactionSummaryBuilder).logEvent - assert.Equal(t, "remoteApiId_unknown", logEvent.TransactionSummary.Proxy.ID) - assert.Equal(t, "", logEvent.TransactionSummary.Proxy.Name) - assert.Equal(t, "stage", logEvent.TransactionSummary.Proxy.Stage) - assert.Equal(t, "version", logEvent.TransactionSummary.Proxy.Version) - assert.Equal(t, 1, logEvent.TransactionSummary.Proxy.Revision) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionEventBuilder().(*transactionEventBuilder) + result := b.SetProxy(tc.proxyID, tc.proxyName) + assert.Equal(t, b, result) + assert.Equal(t, tc.expectedSource, b.logEvent.TransactionEvent.Source) + }) + } } -func TestTransactionSummaryBuilder_ResolveProxyID(t *testing.T) { - tests := []struct { - name string - proxyID string - proxyName string - expected string - description string +func TestEventBuilderSetProxyWithStage(t *testing.T) { + cases := map[string]struct { + proxyID string + proxyName string + proxyStage string + expectedSource string }{ - { - name: "ProxyID with content after prefix and proxyName provided", - proxyID: "remoteApiId_dwight", - proxyName: "schrute", - expected: "remoteApiId_dwight", - description: "Should return original proxyID when it has content after prefix, ignoring proxyName", - }, - { - name: "ProxyID with content after prefix and proxyName empty", - proxyID: "remoteApiId_dwight", - proxyName: "", - expected: "remoteApiId_dwight", - description: "Should return original proxyID when it has content after prefix, even with empty proxyName", - }, - { - name: "ProxyID is just prefix and proxyName provided", - proxyID: "remoteApiId_", - proxyName: "schrute", - expected: "remoteApiId_schrute", - description: "Should use proxyName with prefix when proxyID is just the prefix", - }, - { - name: "Both proxyID and proxyName are empty", - proxyID: "", - proxyName: "", - expected: "remoteApiId_unknown", - description: "Should use unknown with prefix when both are empty", - }, - { - name: "ProxyID is just prefix and proxyName is empty", - proxyID: "remoteApiId_", - proxyName: "", - expected: "remoteApiId_unknown", - description: "Should use unknown with prefix when proxyID is just prefix and proxyName is empty", - }, - { - name: "ProxyID is empty and proxyName provided", - proxyID: "", - proxyName: "schrute", - expected: "remoteApiId_schrute", - description: "Should use proxyName with prefix when proxyID is empty", - }, - { - name: "ProxyID without prefix and proxyName provided", - proxyID: "dwight", - proxyName: "schrute", - expected: "dwight", - description: "Should return original proxyID when it doesn't start with prefix", - }, - { - name: "ProxyID with different prefix and proxyName provided", - proxyID: "differentPrefix_dwight", - proxyName: "schrute", - expected: "differentPrefix_dwight", - description: "Should return original proxyID when it has a different prefix", + "already-prefixed ID preserved regardless of stage": { + proxyID: "remoteApiId_ext-api-001", + proxyName: "My API", + proxyStage: "prod", + expectedSource: "remoteApiId_ext-api-001", + }, + "empty stage does not affect source resolution": { + proxyID: "remoteApiId_ext-api-002", + proxyName: "My API", + proxyStage: "", + expectedSource: "remoteApiId_ext-api-002", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionEventBuilder().(*transactionEventBuilder) + result := b.SetProxyWithStage(tc.proxyID, tc.proxyName, tc.proxyStage) + assert.Equal(t, b, result) + assert.Equal(t, tc.expectedSource, b.logEvent.TransactionEvent.Source) + }) + } +} + +func TestSummaryBuilderResolveProxyID(t *testing.T) { + cases := map[string]struct { + proxyID string + proxyName string + expected string + }{ + "proxyID with content after prefix ignores proxyName": { + proxyID: "remoteApiId_dwight", + proxyName: "schrute", + expected: "remoteApiId_dwight", + }, + "proxyID with content after prefix and empty proxyName": { + proxyID: "remoteApiId_dwight", + proxyName: "", + expected: "remoteApiId_dwight", + }, + "proxyID is just prefix falls back to proxyName": { + proxyID: "remoteApiId_", + proxyName: "schrute", + expected: "remoteApiId_schrute", + }, + "both empty produces unknown": { + proxyID: "", + proxyName: "", + expected: "remoteApiId_unknown", + }, + "proxyID is just prefix and proxyName empty produces unknown": { + proxyID: "remoteApiId_", + proxyName: "", + expected: "remoteApiId_unknown", + }, + "empty proxyID uses proxyName with prefix": { + proxyID: "", + proxyName: "schrute", + expected: "remoteApiId_schrute", + }, + "proxyID without prefix preserved as-is": { + proxyID: "dwight", + proxyName: "schrute", + expected: "dwight", + }, + "proxyID with different prefix preserved as-is": { + proxyID: "differentPrefix_dwight", + proxyName: "schrute", + expected: "differentPrefix_dwight", + }, + "proxyID with multiple underscores preserved": { + proxyID: "remoteApiId_dwight_test_api", + proxyName: "schrute", + expected: "remoteApiId_dwight_test_api", + }, + "proxyName with special characters": { + proxyID: "", + proxyName: "proxy-name.with.dots", + expected: "remoteApiId_proxy-name.with.dots", + }, + "proxyID equals exactly the prefix falls back to proxyName": { + proxyID: SummaryEventProxyIDPrefix, + proxyName: "fallback", + expected: SummaryEventProxyIDPrefix + "fallback", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := transutil.ResolveIDWithPrefix(tt.proxyID, tt.proxyName) - assert.Equal(t, tt.expected, result, tt.description) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + result := transutil.ResolveIDWithPrefix(tc.proxyID, tc.proxyName) + assert.Equal(t, tc.expected, result) }) } } -func TestTransactionSummaryBuilder_SetProxyWithStageVersion(t *testing.T) { - tests := []struct { - name string +func TestSummaryBuilderSetProxyWithStageVersion(t *testing.T) { + cases := map[string]struct { proxyID string proxyName string proxyStage string @@ -550,8 +610,7 @@ func TestTransactionSummaryBuilder_SetProxyWithStageVersion(t *testing.T) { revision int expectedID string }{ - { - name: "Complete proxy information with content after prefix", + "complete proxy information with content after prefix": { proxyID: "remoteApiId_dwight", proxyName: "schrute", proxyStage: "prod", @@ -559,8 +618,7 @@ func TestTransactionSummaryBuilder_SetProxyWithStageVersion(t *testing.T) { revision: 1, expectedID: "remoteApiId_dwight", }, - { - name: "Proxy ID is just prefix, use proxyName", + "proxy ID is just prefix falls back to proxyName": { proxyID: "remoteApiId_", proxyName: "schrute", proxyStage: "test", @@ -568,8 +626,7 @@ func TestTransactionSummaryBuilder_SetProxyWithStageVersion(t *testing.T) { revision: 2, expectedID: "remoteApiId_schrute", }, - { - name: "Empty proxy information, use unknown", + "empty proxy information produces unknown": { proxyID: "", proxyName: "", proxyStage: "", @@ -577,89 +634,93 @@ func TestTransactionSummaryBuilder_SetProxyWithStageVersion(t *testing.T) { revision: 0, expectedID: "remoteApiId_unknown", }, + "both empty with stage and version produces unknown": { + proxyID: "", + proxyName: "", + proxyStage: "stage", + proxyVersion: "version", + revision: 1, + expectedID: "remoteApiId_unknown", + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - builder := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) - - result := builder.SetProxyWithStageVersion(tt.proxyID, tt.proxyName, tt.proxyStage, tt.proxyVersion, tt.revision) - - // Verify the builder returned correctly - assert.Equal(t, builder, result) - - // Verify the proxy was set correctly - assert.NotNil(t, builder.logEvent.TransactionSummary.Proxy) - assert.Equal(t, tt.expectedID, builder.logEvent.TransactionSummary.Proxy.ID) - assert.Equal(t, tt.proxyName, builder.logEvent.TransactionSummary.Proxy.Name) - assert.Equal(t, tt.proxyStage, builder.logEvent.TransactionSummary.Proxy.Stage) - assert.Equal(t, tt.proxyVersion, builder.logEvent.TransactionSummary.Proxy.Version) - assert.Equal(t, tt.revision, builder.logEvent.TransactionSummary.Proxy.Revision) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) + result := b.SetProxyWithStageVersion(tc.proxyID, tc.proxyName, tc.proxyStage, tc.proxyVersion, tc.revision) + assert.Equal(t, b, result) + assert.NotNil(t, b.logEvent.TransactionSummary.Proxy) + assert.Equal(t, tc.expectedID, b.logEvent.TransactionSummary.Proxy.ID) + assert.Equal(t, tc.proxyName, b.logEvent.TransactionSummary.Proxy.Name) + assert.Equal(t, tc.proxyStage, b.logEvent.TransactionSummary.Proxy.Stage) + assert.Equal(t, tc.proxyVersion, b.logEvent.TransactionSummary.Proxy.Version) + assert.Equal(t, tc.revision, b.logEvent.TransactionSummary.Proxy.Revision) }) } } -func TestTransactionSummaryBuilder_SetProxy_ChainedCalls(t *testing.T) { - builder := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) - - // Test that SetProxy calls SetProxyWithStage with empty stage - result := builder.SetProxy("remoteApiId_dwight", "proxyName", 1) - assert.Equal(t, builder, result) - assert.Equal(t, "remoteApiId_dwight", builder.logEvent.TransactionSummary.Proxy.ID) - assert.Equal(t, "proxyName", builder.logEvent.TransactionSummary.Proxy.Name) - assert.Equal(t, "", builder.logEvent.TransactionSummary.Proxy.Stage) - assert.Equal(t, "", builder.logEvent.TransactionSummary.Proxy.Version) - assert.Equal(t, 1, builder.logEvent.TransactionSummary.Proxy.Revision) -} +func TestSummaryBuilderSetProxy(t *testing.T) { + cases := map[string]struct { + proxyID string + proxyName string + revision int + expectedID string + wantStage string + wantVer string + }{ + "SetProxy delegates with empty stage and version": { + proxyID: "remoteApiId_dwight", + proxyName: "proxyName", + revision: 1, + expectedID: "remoteApiId_dwight", + wantStage: "", + wantVer: "", + }, + } -func TestTransactionSummaryBuilder_SetProxyWithStage_ChainedCalls(t *testing.T) { - builder := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) - - // Test that SetProxyWithStage calls SetProxyWithStageVersion with empty version - result := builder.SetProxyWithStage("remoteApiId_dwight", "proxyName", "prod", 1) - assert.Equal(t, builder, result) - assert.Equal(t, "remoteApiId_dwight", builder.logEvent.TransactionSummary.Proxy.ID) - assert.Equal(t, "proxyName", builder.logEvent.TransactionSummary.Proxy.Name) - assert.Equal(t, "prod", builder.logEvent.TransactionSummary.Proxy.Stage) - assert.Equal(t, "", builder.logEvent.TransactionSummary.Proxy.Version) - assert.Equal(t, 1, builder.logEvent.TransactionSummary.Proxy.Revision) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) + result := b.SetProxy(tc.proxyID, tc.proxyName, tc.revision) + assert.Equal(t, b, result) + assert.Equal(t, tc.expectedID, b.logEvent.TransactionSummary.Proxy.ID) + assert.Equal(t, tc.proxyName, b.logEvent.TransactionSummary.Proxy.Name) + assert.Equal(t, tc.wantStage, b.logEvent.TransactionSummary.Proxy.Stage) + assert.Equal(t, tc.wantVer, b.logEvent.TransactionSummary.Proxy.Version) + assert.Equal(t, tc.revision, b.logEvent.TransactionSummary.Proxy.Revision) + }) + } } -func TestTransactionSummaryBuilder_ResolveProxyID_EdgeCases(t *testing.T) { - tests := []struct { - name string - proxyID string - proxyName string - expected string - description string +func TestSummaryBuilderSetProxyWithStage(t *testing.T) { + cases := map[string]struct { + proxyID string + proxyName string + proxyStage string + revision int + expectedID string + wantVer string }{ - { - name: "ProxyID with multiple underscores", - proxyID: "remoteApiId_dwight_test_api", - proxyName: "schrute", - expected: "remoteApiId_dwight_test_api", - description: "Should preserve full proxyID with multiple underscores", - }, - { - name: "ProxyName with special characters", - proxyID: "", - proxyName: "proxy-name.with.dots", - expected: "remoteApiId_proxy-name.with.dots", - description: "Should handle proxyName with special characters", - }, - { - name: "ProxyID equals exactly the prefix", - proxyID: SummaryEventProxyIDPrefix, - proxyName: "fallback", - expected: SummaryEventProxyIDPrefix + "fallback", - description: "Should treat proxyID that equals prefix as empty content", + "SetProxyWithStage delegates with empty version": { + proxyID: "remoteApiId_dwight", + proxyName: "proxyName", + proxyStage: "prod", + revision: 1, + expectedID: "remoteApiId_dwight", + wantVer: "", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := transutil.ResolveIDWithPrefix(tt.proxyID, tt.proxyName) - assert.Equal(t, tt.expected, result, tt.description) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionSummaryBuilder().(*transactionSummaryBuilder) + result := b.SetProxyWithStage(tc.proxyID, tc.proxyName, tc.proxyStage, tc.revision) + assert.Equal(t, b, result) + assert.Equal(t, tc.expectedID, b.logEvent.TransactionSummary.Proxy.ID) + assert.Equal(t, tc.proxyName, b.logEvent.TransactionSummary.Proxy.Name) + assert.Equal(t, tc.proxyStage, b.logEvent.TransactionSummary.Proxy.Stage) + assert.Equal(t, tc.wantVer, b.logEvent.TransactionSummary.Proxy.Version) + assert.Equal(t, tc.revision, b.logEvent.TransactionSummary.Proxy.Revision) }) } } diff --git a/pkg/transaction/metric/apimetric.go b/pkg/transaction/metric/apimetric.go index f103c672a..e46af10ed 100644 --- a/pkg/transaction/metric/apimetric.go +++ b/pkg/transaction/metric/apimetric.go @@ -24,6 +24,8 @@ type APIMetric struct { EventID string `json:"-"` StartTime time.Time `json:"-"` Unit *models.Unit `json:"unit,omitempty"` + // APIServiceRevisionID is populated by the agents-controller via CentralMetricBuilder.SetAPIServiceRevision. + APIServiceRevisionID string `json:"-"` } // GetStartTime - Returns the start time for subscription metric diff --git a/pkg/transaction/metric/centralmetric.go b/pkg/transaction/metric/centralmetric.go index ea90b1b24..7101b9797 100644 --- a/pkg/transaction/metric/centralmetric.go +++ b/pkg/transaction/metric/centralmetric.go @@ -127,22 +127,59 @@ func (b *CentralMetricBuilder) SetEventID(id string) *CentralMetricBuilder { return b } +func (b *CentralMetricBuilder) SetVersion(v string) *CentralMetricBuilder { + b.Version = v + return b +} + +func (b *CentralMetricBuilder) SetAPICDeployment(d string) *CentralMetricBuilder { + b.APICDeployment = d + return b +} + +func (b *CentralMetricBuilder) SetEnvironmentRuntimeType(t string) *CentralMetricBuilder { + if b.Environment == nil { + b.Environment = &EnvironmentInfo{} + } + b.Environment.RuntimeType = t + return b +} + +func (b *CentralMetricBuilder) SetAPIServiceRevision(r *models.ResourceReference) *CentralMetricBuilder { + b.APIServiceRevision = r + return b +} + +func (b *CentralMetricBuilder) SetReporter(r *Reporter) *CentralMetricBuilder { + b.Reporter = r + return b +} + func (b *CentralMetricBuilder) Build() *centralMetric { return b.centralMetric } +// EnvironmentInfo holds the environment runtime classification for metric v3 events. +type EnvironmentInfo struct { + RuntimeType string `json:"runtimeType,omitempty"` +} + type centralMetric struct { - Marketplace *models.MarketplaceReference `json:"marketplace,omitempty"` - Subscription *models.ResourceReference `json:"subscription,omitempty"` - App *models.ApplicationResourceReference `json:"application,omitempty"` - Product *models.ProductResourceReference `json:"product,omitempty"` - API *models.APIResourceReference `json:"api,omitempty"` - AssetResource *models.ResourceReference `json:"assetResource,omitempty"` - ProductPlan *models.ResourceReference `json:"productPlan,omitempty"` - Units *Units `json:"units,omitempty"` - Reporter *Reporter `json:"reporter,omitempty"` - Observation *models.ObservationDetails `json:"-"` - EventID string `json:"-"` + Version string `json:"version,omitempty"` + APICDeployment string `json:"apicDeployment,omitempty"` + Environment *EnvironmentInfo `json:"environment,omitempty"` + Marketplace *models.MarketplaceReference `json:"marketplace,omitempty"` + Subscription *models.ResourceReference `json:"subscription,omitempty"` + App *models.ApplicationResourceReference `json:"application,omitempty"` + Product *models.ProductResourceReference `json:"product,omitempty"` + API *models.APIResourceReference `json:"api,omitempty"` + AssetResource *models.ResourceReference `json:"assetResource,omitempty"` + APIServiceRevision *models.ResourceReference `json:"apiServiceRevision,omitempty"` + ProductPlan *models.ResourceReference `json:"productPlan,omitempty"` + Units *Units `json:"units,omitempty"` + Reporter *Reporter `json:"reporter,omitempty"` + Observation *models.ObservationDetails `json:"-"` + EventID string `json:"-"` } // GetStartTime - Returns the start time for subscription metric @@ -163,9 +200,30 @@ func (a *centralMetric) GetEventID() string { func (a *centralMetric) GetLogFields() logrus.Fields { fields := logrus.Fields{ "id": a.EventID, + "dataVersion": a.Version, "startTimestamp": a.Observation.Start, "endTimestamp": a.Observation.End, } + fields = a.addOwnerFields(fields) + fields = a.addReferenceFields(fields) + fields = a.addUnitFields(fields) + return fields +} + +func (a *centralMetric) addOwnerFields(fields logrus.Fields) logrus.Fields { + if a.Environment != nil { + fields["runtimeType"] = a.Environment.RuntimeType + } + if a.API != nil && a.API.Owner != nil { + fields["apiOwnerType"] = a.API.Owner.Type + } + if a.App != nil && a.App.Owner != nil { + fields["appOwnerType"] = a.App.Owner.Type + } + return fields +} + +func (a *centralMetric) addReferenceFields(fields logrus.Fields) logrus.Fields { if a.Subscription != nil { fields = a.Subscription.GetLogFields(fields, "subscriptionID") } @@ -184,23 +242,15 @@ func (a *centralMetric) GetLogFields() logrus.Fields { if a.ProductPlan != nil { fields = a.ProductPlan.GetLogFields(fields, "productPlanID") } + return fields +} - // add transaction unit info and custom units if they exist +func (a *centralMetric) addUnitFields(fields logrus.Fields) logrus.Fields { if a.Units == nil { return fields } if a.Units.Transactions != nil { - if a.Units.Transactions.Quota != nil { - fields = a.Units.Transactions.Quota.GetLogFields(fields, "transactionQuotaID") - } - fields["transactionCount"] = a.Units.Transactions.Count - fields["status"] = a.Units.Transactions.Status - fields["minResponse"] = a.Units.Transactions.Response.Min - fields["maxResponse"] = a.Units.Transactions.Response.Max - fields["avgResponse"] = a.Units.Transactions.Response.Avg - } - if len(a.Units.CustomUnits) == 0 { - return fields + fields = a.addTransactionFields(fields) } for k, u := range a.Units.CustomUnits { if u.Quota != nil { @@ -211,6 +261,19 @@ func (a *centralMetric) GetLogFields() logrus.Fields { return fields } +func (a *centralMetric) addTransactionFields(fields logrus.Fields) logrus.Fields { + t := a.Units.Transactions + if t.Quota != nil { + fields = t.Quota.GetLogFields(fields, "transactionQuotaID") + } + fields["transactionCount"] = t.Count + fields["status"] = t.Status + fields["minResponse"] = t.Response.Min + fields["maxResponse"] = t.Response.Max + fields["avgResponse"] = t.Response.Avg + return fields +} + // getKey - returns the cache key for the metric func (a *centralMetric) getKey() string { subID := unknown diff --git a/pkg/transaction/metric/centralmetric_test.go b/pkg/transaction/metric/centralmetric_test.go new file mode 100644 index 000000000..af9a51947 --- /dev/null +++ b/pkg/transaction/metric/centralmetric_test.go @@ -0,0 +1,97 @@ +package metric + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Axway/agent-sdk/pkg/transaction/models" +) + +func TestCentralMetricBuilderSetVersion(t *testing.T) { + cases := map[string]struct { + version string + }{ + "sets version 3": {version: "3"}, + "sets empty string": {version: ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewCentralMetricBuilder().SetVersion(tc.version) + assert.Equal(t, tc.version, b.Build().Version) + }) + } +} + +func TestCentralMetricBuilderSetAPICDeployment(t *testing.T) { + cases := map[string]struct { + deployment string + }{ + "sets prod": {deployment: "prod"}, + "sets teams": {deployment: "teams"}, + "sets empty": {deployment: ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewCentralMetricBuilder().SetAPICDeployment(tc.deployment) + assert.Equal(t, tc.deployment, b.Build().APICDeployment) + }) + } +} + +func TestCentralMetricBuilderSetEnvironmentRuntimeType(t *testing.T) { + cases := map[string]struct { + calls []string + want string + }{ + "sets managed": {calls: []string{"managed"}, want: "managed"}, + "sets unmanaged": {calls: []string{"unmanaged"}, want: "unmanaged"}, + "sets unknown": {calls: []string{"unknown"}, want: "unknown"}, + "overwrites on second call": {calls: []string{"managed", "unmanaged"}, want: "unmanaged"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewCentralMetricBuilder() + for _, rt := range tc.calls { + b.SetEnvironmentRuntimeType(rt) + } + result := b.Build() + assert.NotNil(t, result.Environment) + assert.Equal(t, tc.want, result.Environment.RuntimeType) + }) + } +} + +func TestCentralMetricBuilderSetAPIServiceRevision(t *testing.T) { + ref := &models.ResourceReference{ID: "rev-123"} + cases := map[string]struct { + ref *models.ResourceReference + want *models.ResourceReference + }{ + "sets reference": {ref: ref, want: ref}, + "sets nil": {ref: nil, want: nil}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewCentralMetricBuilder().SetAPIServiceRevision(tc.ref) + assert.Equal(t, tc.want, b.Build().APIServiceRevision) + }) + } +} + +func TestCentralMetricBuilderSetReporter(t *testing.T) { + reporter := &Reporter{AgentName: "test-agent", AgentVersion: "1.0.0"} + cases := map[string]struct { + reporter *Reporter + want *Reporter + }{ + "sets reporter": {reporter: reporter, want: reporter}, + "sets nil": {reporter: nil, want: nil}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewCentralMetricBuilder().SetReporter(tc.reporter) + assert.Equal(t, tc.want, b.Build().Reporter) + }) + } +} diff --git a/pkg/transaction/metric/contract_test.go b/pkg/transaction/metric/contract_test.go new file mode 100644 index 000000000..5663065d2 --- /dev/null +++ b/pkg/transaction/metric/contract_test.go @@ -0,0 +1,214 @@ +package metric + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Axway/agent-sdk/pkg/agent" + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/transaction/models" +) + +const ( + contractAgentName = "contract-agent" + contractAppID = "app-ctr-3" +) + +func contractAPIServiceRI(apiID string, owner *v1.Owner) *v1.ResourceInstance { + svc := management.NewAPIService("svc-"+apiID, "env-contract") + svc.SubResources = map[string]interface{}{ + "x-agent-details": map[string]interface{}{ + "externalAPIID": apiID, + }, + } + svc.Owner = owner + ri, _ := svc.AsInstance() + return ri +} + +func contractManagedAppRI(name string, owner *v1.Owner) *v1.ResourceInstance { + app := management.NewManagedApplication(name, "env-contract") + app.Marketplace = management.ManagedApplicationMarketplace{ + Name: "mp-contract", + Resource: management.ManagedApplicationMarketplaceResource{ + Owner: owner, + }, + } + ri, _ := app.AsInstance() + return ri +} + +// TestContractMetricV3 validates the metric v3 JSON shape, owner resolution, and removed fields. +func TestContractMetricV3(t *testing.T) { + cases := map[string]struct { + setup func() + input *APIMetric + check func(t *testing.T, cm *centralMetric, raw string) + }{ + "version and top-level org fields": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + }, + input: &APIMetric{ + EventID: "contract-metric-1", + StatusCode: "200", + Count: 10, + Observation: models.ObservationDetails{ + Start: 1000, + End: 1500, + }, + API: models.APIDetails{ + ID: "contract-api-1", + Name: "contract-api", + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + assert.Equal(t, "3", cm.Version) + require.NotNil(t, cm.Environment) + assert.Equal(t, runtimeTypeUnmanaged, cm.Environment.RuntimeType) + + event := V4Event{ + Version: "4", + Event: "api.transaction.status.metric", + Org: "contract-org-guid", + Data: cm, + } + b, err := json.Marshal(event) + require.NoError(t, err) + s := string(b) + assert.Contains(t, s, `"version":"4"`) + assert.Contains(t, s, `"api.transaction.status.metric"`) + assert.Contains(t, s, `"org":"contract-org-guid"`) + assert.NotContains(t, s, `"app":`) + assert.Contains(t, s, `"version":"3"`) + }, + }, + "duration equals sum of transaction response times": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + }, + input: &APIMetric{ + EventID: "contract-metric-dur", + StatusCode: "200", + Count: 5, + Response: ResponseMetrics{ + Avg: 300, + }, + Observation: models.ObservationDetails{ + Start: 2000, + End: 3500, + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + require.NotNil(t, cm.Units) + require.NotNil(t, cm.Units.Transactions) + assert.Equal(t, int64(1500), cm.Units.Transactions.Duration) + assert.Contains(t, raw, `"duration":1500`) + }, + }, + "api owner populated from cache": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + agent.GetCacheManager().AddAPIService(contractAPIServiceRI("ctr-api-2", &v1.Owner{Type: v1.TeamOwner, ID: "team-ctr-2"})) + }, + input: &APIMetric{ + EventID: "contract-metric-owner", + StatusCode: "200", + Count: 1, + Observation: models.ObservationDetails{Start: 100, End: 200}, + API: models.APIDetails{ + ID: "ctr-api-2", + Name: "owner-api", + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + require.NotNil(t, cm.API) + require.NotNil(t, cm.API.Owner, "api.owner must be populated when cache has an APIService with an owner") + assert.Equal(t, "team", cm.API.Owner.Type) + assert.Equal(t, "team-ctr-2", cm.API.Owner.TeamGUID) + }, + }, + "api owner falls back to unknown on cache miss": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + }, + input: &APIMetric{ + EventID: "contract-metric-miss", + StatusCode: "200", + Count: 1, + Observation: models.ObservationDetails{Start: 100, End: 200}, + API: models.APIDetails{ + ID: "not-in-cache", + Name: "miss-api", + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + require.NotNil(t, cm.API) + require.NotNil(t, cm.API.Owner) + assert.Equal(t, "unknown", cm.API.Owner.Type) + }, + }, + "application owner populated from cache": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + agent.GetCacheManager().AddManagedApplication(contractManagedAppRI(contractAppID, &v1.Owner{Type: v1.TeamOwner, ID: "app-team-3"})) + }, + input: &APIMetric{ + EventID: "contract-metric-app-owner", + StatusCode: "200", + Count: 1, + Observation: models.ObservationDetails{Start: 100, End: 200}, + App: models.AppDetails{ + ID: contractAppID, + Name: contractAppID, + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + require.NotNil(t, cm.App) + require.NotNil(t, cm.App.Owner, "application.owner must be populated when cache has a managed application with an owner") + assert.Equal(t, "team", cm.App.Owner.Type) + assert.Equal(t, "app-team-3", cm.App.Owner.TeamGUID) + }, + }, + "removed fields absent from serialized JSON": { + setup: func() { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: contractAgentName})) + }, + input: &APIMetric{ + EventID: "contract-metric-removed", + StatusCode: "200", + Count: 1, + Observation: models.ObservationDetails{Start: 100, End: 200}, + API: models.APIDetails{ + ID: "ctr-api-r", + Name: "removed-api", + Revision: 2, + }, + }, + check: func(t *testing.T, cm *centralMetric, raw string) { + assert.NotContains(t, raw, `"team":{`) + assert.NotContains(t, raw, `"apiServiceInstance"`) + assert.NotContains(t, raw, `"proxy.apiServiceInstance"`) + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tc.setup() + + cm := centralMetricFromAPIMetric(tc.input) + require.NotNil(t, cm) + + b, err := json.Marshal(cm) + require.NoError(t, err) + + tc.check(t, cm, string(b)) + }) + } +} diff --git a/pkg/transaction/metric/definition.go b/pkg/transaction/metric/definition.go index 4e1dd86d4..cf44160dc 100644 --- a/pkg/transaction/metric/definition.go +++ b/pkg/transaction/metric/definition.go @@ -23,6 +23,9 @@ const ( transactionCountMetric = "transaction.count" transactionVolumeMetric = "transaction.volume" unknown = "unknown" + metricDataVersion = "3" + runtimeTypeManaged = "managed" + runtimeTypeUnmanaged = "unmanaged" ) type transactionContext struct { @@ -78,7 +81,6 @@ type cachedMetric struct { // V4EventDistribution - represents V4 distribution type V4EventDistribution struct { Environment string `json:"environment"` - Version string `json:"version"` } // V4Session - represents V4 session @@ -94,12 +96,13 @@ type V4Data interface { GetLogFields() logrus.Fields } -// V4Event - represents V7 event +// V4Event is the top-level event envelope sent to the insights pipeline. type V4Event struct { ID string `json:"id"` Timestamp int64 `json:"timestamp"` Event string `json:"event"` - App string `json:"app,omitempty"` // ORG GUID + Org string `json:"org,omitempty"` + App string `json:"app,omitempty"` Version string `json:"version"` Distribution *V4EventDistribution `json:"distribution"` Data V4Data `json:"data"` @@ -180,8 +183,8 @@ func (t ISO8601Time) MarshalJSON() ([]byte, error) { } type Reporter struct { - AgentVersion string `json:"agentVersion,omitempty"` - AgentType string `json:"agentType,omitempty"` + AgentVersion string `json:"version,omitempty"` + AgentType string `json:"type,omitempty"` AgentSDKVersion string `json:"agentSDKVersion,omitempty"` AgentName string `json:"agentName,omitempty"` ObservationDelta int64 `json:"observationDelta,omitempty"` diff --git a/pkg/transaction/metric/metricscollector.go b/pkg/transaction/metric/metricscollector.go index 1eead0cf2..3e2fe90da 100644 --- a/pkg/transaction/metric/metricscollector.go +++ b/pkg/transaction/metric/metricscollector.go @@ -425,14 +425,20 @@ func (c *collector) createMetric(detail transactionContext) *centralMetric { // Go get the access request and managed app accessRequest, managedApp := c.getAccessRequestAndManagedApp(agent.GetCacheManager(), detail) + apicDeployment, _, runtimeType := centralConfigFields() + me := ¢ralMetric{ - Marketplace: transutil.GetMarketplaceDetails(managedApp), - Subscription: c.createSubscriptionDetail(accessRequest), - App: c.createAppDetail(managedApp), - Product: c.getProduct(accessRequest), - API: c.createAPIDetail(detail.APIDetails), - AssetResource: c.getAssetResource(accessRequest), - ProductPlan: c.getProductPlan(accessRequest), + Version: metricDataVersion, + APICDeployment: apicDeployment, + Environment: &EnvironmentInfo{RuntimeType: runtimeType}, + Marketplace: transutil.GetMarketplaceDetails(managedApp), + Subscription: c.createSubscriptionDetail(accessRequest), + App: c.createAppDetail(managedApp), + Product: c.getProduct(accessRequest), + API: c.createAPIDetail(detail.APIDetails), + AssetResource: c.getAssetResource(accessRequest), + APIServiceRevision: c.getAPIServiceRevision(accessRequest), + ProductPlan: c.getProductPlan(accessRequest), Observation: &models.ObservationDetails{ Start: now().Unix(), }, @@ -617,6 +623,7 @@ func (c *collector) createAppDetail(appRI *v1.ResourceInstance) *models.Applicat ID: appRef.ID, }, ConsumerOrgID: orgID, + Owner: transutil.ResolveAppOwnerFromManagedApp(appRI), } } @@ -625,16 +632,32 @@ func (c *collector) createAPIDetail(api models.APIDetails) *models.APIResourceRe ResourceReference: models.ResourceReference{ ID: api.ID, }, - Name: api.Name, - APIOwnerID: api.TeamID, + Name: api.Name, } - svc := agent.GetCacheManager().GetAPIServiceWithAPIID(strings.TrimPrefix(api.ID, transutil.SummaryEventProxyIDPrefix)) + cacheManager := agent.GetCacheManager() + svc := cacheManager.GetAPIServiceWithAPIID(strings.TrimPrefix(api.ID, transutil.SummaryEventProxyIDPrefix)) if svc != nil { ref.APIServiceID = svc.Metadata.ID } + ref.Owner = transutil.ResolveAPIOwner(api.ID, cacheManager) return ref } +// getAPIServiceRevision uses the APIServiceInstance reference on the AccessRequest as the +// revision identifier. AccessRequests do not carry a direct APIServiceRevision reference. +func (c *collector) getAPIServiceRevision(accessRequest *management.AccessRequest) *models.ResourceReference { + if accessRequest == nil { + return nil + } + + ref := accessRequest.GetReferenceByGVK(management.APIServiceInstanceGVK()) + if ref.ID == "" { + return nil + } + + return &models.ResourceReference{ID: ref.ID} +} + func (c *collector) getAssetResource(accessRequest *management.AccessRequest) *models.ResourceReference { if accessRequest == nil { return nil @@ -667,6 +690,7 @@ func (c *collector) getProduct(accessRequest *management.AccessRequest) *models. ID: productRef.ID, }, VersionID: releaseRef.ID, + Owner: transutil.ResolveProductOwner(accessRequest.GetEmbeddedReferenceByGVK(catalog.PublishedProductGVK())), } } @@ -727,6 +751,12 @@ func (c *collector) cleanup() { } func (c *collector) getOrgGUID() string { + return GetOrgGUID() +} + +// GetOrgGUID parses the provider org GUID from the central auth token JWT. +// Returns empty string if the token is unavailable or the claim is absent. +func GetOrgGUID() string { authToken, _ := agent.GetCentralAuthToken() parser := jwt.NewParser(jwt.WithoutClaimsValidation()) claims := jwt.MapClaims{} @@ -734,9 +764,7 @@ func (c *collector) getOrgGUID() string { if err != nil { return "" } - - claim, ok := claims["org_guid"] - if ok { + if claim, ok := claims["org_guid"]; ok { return claim.(string) } return "" @@ -940,9 +968,10 @@ func (c *collector) setMetricCounters(logger log.FieldLogger, metricData *centra } } -func (c *collector) setMetricsFromHistogram(metrics *centralMetric, histogram metrics.Histogram) { - metrics.Units.Transactions.Count = histogram.Count() - metrics.Units.Transactions.Response = &ResponseMetrics{ +func (c *collector) setMetricsFromHistogram(m *centralMetric, histogram metrics.Histogram) { + m.Units.Transactions.Count = histogram.Count() + m.Units.Transactions.Duration = int64(histogram.Mean() * float64(histogram.Count())) + m.Units.Transactions.Response = &ResponseMetrics{ Max: histogram.Max(), Min: histogram.Min(), Avg: histogram.Mean(), @@ -975,11 +1004,10 @@ func (c *collector) createV4Event(startTime int64, v4data V4Data) V4Event { ID: v4data.GetEventID(), Timestamp: startTime, Event: metricEvent, - App: c.orgGUID, + Org: c.orgGUID, Version: "4", Distribution: &V4EventDistribution{ Environment: agent.GetCentralConfig().GetEnvironmentID(), - Version: "1", }, Data: v4data, } @@ -1081,36 +1109,11 @@ func (c *collector) logMetric(msg string, metric *centralMetric) { func (c *collector) cleanupMetricCounters(histogram metrics.Histogram, counters map[string]metrics.Counter, metric *centralMetric) { c.metricMapLock.Lock() defer c.metricMapLock.Unlock() + subID, appID, apiID, group := metric.getKeyParts() - if consumerAppMap, ok := c.metricMap[subID]; ok { - if apiMap, ok := consumerAppMap[appID]; ok { - if apiStatusMap, ok := apiMap[apiID]; ok { - if _, ok := apiStatusMap[group]; ok { - c.storage.removeMetric(apiStatusMap[group]) - delete(c.metricMap[subID][appID][apiID], group) - histogram.Clear() - } + c.removeMetricEntries(subID, appID, apiID, group, histogram, counters) + c.removeEmptyKeys(subID, appID, apiID) - // clean any counters, if needed - for k := range counters { - if apiStatusMap[k] != nil { - c.storage.removeMetric(apiStatusMap[k]) - } - delete(c.metricMap[subID][appID][apiID], k) - delete(counters, k) - } - } - if len(c.metricMap[subID][appID][apiID]) == 0 { - delete(c.metricMap[subID][appID], apiID) - } - } - if len(c.metricMap[subID][appID]) == 0 { - delete(c.metricMap[subID], appID) - } - } - if len(c.metricMap[subID]) == 0 { - delete(c.metricMap, subID) - } c.logger. WithField(startTimestampStr, util.ConvertTimeToMillis(c.usageStartTime)). WithField(endTimestampStr, util.ConvertTimeToMillis(c.usageEndTime)). @@ -1118,6 +1121,39 @@ func (c *collector) cleanupMetricCounters(histogram metrics.Histogram, counters Info("Published metrics report for API") } +func (c *collector) removeMetricEntries(subID, appID, apiID, group string, histogram metrics.Histogram, counters map[string]metrics.Counter) { + apiStatusMap, ok := c.metricMap[subID][appID][apiID] + if !ok { + return + } + + if _, ok := apiStatusMap[group]; ok { + c.storage.removeMetric(apiStatusMap[group]) + delete(c.metricMap[subID][appID][apiID], group) + histogram.Clear() + } + + for k := range counters { + if apiStatusMap[k] != nil { + c.storage.removeMetric(apiStatusMap[k]) + } + delete(c.metricMap[subID][appID][apiID], k) + delete(counters, k) + } +} + +func (c *collector) removeEmptyKeys(subID, appID, apiID string) { + if len(c.metricMap[subID][appID][apiID]) == 0 { + delete(c.metricMap[subID][appID], apiID) + } + if len(c.metricMap[subID][appID]) == 0 { + delete(c.metricMap[subID], appID) + } + if len(c.metricMap[subID]) == 0 { + delete(c.metricMap, subID) + } +} + func GetStatusText(statusCode string) string { return sampling.GetStatusFromCodeString(statusCode).String() } diff --git a/pkg/transaction/metric/metricscollector_test.go b/pkg/transaction/metric/metricscollector_test.go index 74708484f..156a753b3 100644 --- a/pkg/transaction/metric/metricscollector_test.go +++ b/pkg/transaction/metric/metricscollector_test.go @@ -1,9 +1,10 @@ package metric import ( + "encoding/base64" "encoding/json" - "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -12,6 +13,9 @@ import ( "testing" "time" + "github.com/rcrowley/go-metrics" + "github.com/stretchr/testify/assert" + "github.com/Axway/agent-sdk/pkg/agent" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" @@ -21,7 +25,21 @@ import ( "github.com/Axway/agent-sdk/pkg/traceability" "github.com/Axway/agent-sdk/pkg/transaction/models" "github.com/Axway/agent-sdk/pkg/util/healthcheck" - "github.com/stretchr/testify/assert" +) + +const ( + testEnvID = "267bd671-e5e2-4679-bcc3-bbe7b70f30fd" + testInstID = "inst-1" + testInstName = "instance-1" + testManagedApp1 = "managed-app-1" + testManagedApp2 = "managed-app-2" + testConsumerOrg = "test-consumer-org" + testAccessReq1 = "access-req-1" + testAccessReq2 = "access-req-2" + testSubscription1 = "subscription-1" + testSubscription2 = "subscription-2" + testCronSchedule = "* * * * *" + testLighthouse = "/lighthouse" ) var ( @@ -76,7 +94,7 @@ func createCentralCfg(url, env string) *config.CentralConfiguration { return cfg } -var accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNjE0NjA0NzE0LCJleHAiOjE2NDYxNDA3MTQsImF1ZCI6InRlc3RhdWQiLCJzdWIiOiIxMjM0NTYiLCJvcmdfZ3VpZCI6IjEyMzQtMTIzNC0xMjM0LTEyMzQifQ.5Uqt0oFhMgmI-sLQKPGkHwknqzlTxv-qs9I_LmZ18LQ" +var accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNjE0NjA0NzE0LCJleHAiOjE2NDYxNDA3MTQsImF1ZCI6InRlc3RhdWQiLCJzdWIiOiIxMjM0NTYiLCJvcmdfZ3VpZCI6IjEyMzQtMTIzNC0xMjM0LTEyMzQifQ.5Uqt0oFhMgmI-sLQKPGkHwknqzlTxv-qs9I_LmZ18LQ" // NOSONAR - expired synthetic JWT used only in mock HTTP handler, not a real credential var teamID = "team123" @@ -94,52 +112,71 @@ type testHTTPServer struct { func (s *testHTTPServer) startServer() { s.server = httptest.NewServer(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - if strings.Contains(req.RequestURI, "/auth") { - token := "{\"access_token\":\"" + accessToken + "\",\"expires_in\": 12235677}" - resp.Write([]byte(token)) - } - if strings.Contains(req.RequestURI, "/lighthouse") { - if s.failUsageEvent { - if s.failUsageResponse != nil { - b, _ := json.Marshal(*s.failUsageResponse) - resp.WriteHeader(s.failUsageResponse.StatusCode) - resp.Write(b) - return - } - resp.WriteHeader(http.StatusBadRequest) - return - } - s.lighthouseEventCount++ - req.ParseMultipartForm(1 << 20) - for _, fileHeaders := range req.MultipartForm.File { - for _, fileHeader := range fileHeaders { - file, err := fileHeader.Open() - if err != nil { - return - } - body, _ := io.ReadAll(file) - var usageEvent UsageEvent - json.Unmarshal(body, &usageEvent) - fmt.Printf("\n\n %+v \n\n", usageEvent) - for _, report := range usageEvent.Report { - for usageType, usage := range report.Usage { - if strings.Index(usageType, "Transactions") > 0 { - s.transactionCount += int(usage) - } else if strings.Index(usageType, "Volume") > 0 { - s.transactionVolume += int(usage) - } - } - s.reportCount++ - } - s.givenGranularity = usageEvent.Granularity - s.eventTimestamp = usageEvent.Timestamp - } - } + switch { + case strings.Contains(req.RequestURI, "/auth"): + s.handleAuth(resp) + case strings.Contains(req.RequestURI, testLighthouse): + s.handleLighthouse(resp, req) } resp.WriteHeader(202) })) } +func (s *testHTTPServer) handleAuth(resp http.ResponseWriter) { + token := "{\"access_token\":\"" + accessToken + "\",\"expires_in\": 12235677}" + resp.Write([]byte(token)) +} + +func (s *testHTTPServer) handleLighthouse(resp http.ResponseWriter, req *http.Request) { + if s.failUsageEvent { + s.writeFailureResponse(resp) + return + } + s.lighthouseEventCount++ + req.ParseMultipartForm(1 << 20) + for _, fileHeaders := range req.MultipartForm.File { + for _, fileHeader := range fileHeaders { + s.processFileHeader(fileHeader) + } + } +} + +func (s *testHTTPServer) writeFailureResponse(resp http.ResponseWriter) { + if s.failUsageResponse != nil { + b, _ := json.Marshal(*s.failUsageResponse) + resp.WriteHeader(s.failUsageResponse.StatusCode) + resp.Write(b) + return + } + resp.WriteHeader(http.StatusBadRequest) +} + +func (s *testHTTPServer) processFileHeader(fileHeader *multipart.FileHeader) { + file, err := fileHeader.Open() + if err != nil { + return + } + body, _ := io.ReadAll(file) + var usageEvent UsageEvent + json.Unmarshal(body, &usageEvent) + s.givenGranularity = usageEvent.Granularity + s.eventTimestamp = usageEvent.Timestamp + for _, report := range usageEvent.Report { + s.tallyReport(report) + } +} + +func (s *testHTTPServer) tallyReport(report UsageReport) { + for usageType, usage := range report.Usage { + if strings.Index(usageType, "Transactions") > 0 { + s.transactionCount += int(usage) + } else if strings.Index(usageType, "Volume") > 0 { + s.transactionVolume += int(usage) + } + } + s.reportCount++ +} + func (s *testHTTPServer) closeServer() { if s.server != nil { s.server.Close() @@ -275,135 +312,92 @@ func runTestHealthcheck() { healthcheck.RunChecks() } -func TestMetricCollector(t *testing.T) { - defer cleanUpCachedMetricFile() - s := &testHTTPServer{} - defer s.closeServer() - s.startServer() - traceability.SetDataDirPath(".") - - cfg := createCentralCfg(s.server.URL, "demo") - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" - cfg.MetricReporting.(*config.MetricReportingConfiguration).Publish = true - cfg.SetEnvironmentID("267bd671-e5e2-4679-bcc3-bbe7b70f30fd") - cmd.BuildDataPlaneType = "Azure" - agent.Initialize(cfg) - - cm := agent.GetCacheManager() - cm.AddAPIServiceInstance(createAPIServiceInstance("inst-1", "instance-1", "111")) - - cm.AddManagedApplication(createManagedApplication("app-1", "managed-app-1", "")) - cm.AddManagedApplication(createManagedApplication("app-2", "managed-app-2", "test-consumer-org")) - - cm.AddAccessRequest(createAccessRequest("ac-1", "access-req-1", "managed-app-1", "inst-1", "instance-1", "subscription-1")) - cm.AddAccessRequest(createAccessRequest("ac-2", "access-req-2", "managed-app-2", "inst-1", "instance-1", "subscription-2")) - - myCollector := createMetricCollector() - metricCollector := myCollector.(*collector) +type metricCollectorTestCase struct { + name string + loopCount int + retryBatchCount int + apiTransactionCount []int + failUsageEventOnServer []bool + failUsageResponseOnServer []*UsageResponse + expectedLHEvents []int + expectedTransactionCount []int + trackVolume bool + expectedTransactionVolume []int + expectedMetricEventsAcked int + appName string + publishPrior bool + hcStatus healthcheck.StatusLevel + skipWaitForPub bool +} - testCases := []struct { - name string - loopCount int - retryBatchCount int - apiTransactionCount []int - failUsageEventOnServer []bool - failUsageResponseOnServer []*UsageResponse - expectedLHEvents []int - expectedTransactionCount []int - trackVolume bool - expectedTransactionVolume []int - expectedMetricEventsAcked int - appName string - publishPrior bool - hcStatus healthcheck.StatusLevel - skipWaitForPub bool - }{ - // Success case with no usage report +func metricCollectorTestCases() []metricCollectorTestCase { + return []metricCollectorTestCase{ { name: "WithUsageNoUsageReport", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{0}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{0}, expectedTransactionCount: []int{0}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 0, skipWaitForPub: true, }, - // Success case with no app detail { - name: "WithUsage", + name: "WithUsageNoApp", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{5}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{1}, expectedTransactionCount: []int{5}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 1, // API metric + no Provider subscription metric + expectedMetricEventsAcked: 1, }, { name: "WithUsageWithPriorPublish", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{5}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{1}, expectedTransactionCount: []int{5}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 1, // API metric + no Provider subscription metric + expectedMetricEventsAcked: 1, publishPrior: true, }, - // Success case { - name: "WithUsage", + name: "WithUsageProviderApp", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{5}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{1}, expectedTransactionCount: []int{5}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 1, // API metric + Provider subscription metric - appName: "managed-app-1", + expectedMetricEventsAcked: 1, + appName: testManagedApp1, }, - // Success case with consumer metric event { - name: "WithUsage", + name: "WithUsageConsumerApp", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{5}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{1}, expectedTransactionCount: []int{5}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 1, // API metric + Provider + Consumer subscription metric - appName: "managed-app-2", + expectedMetricEventsAcked: 1, + appName: testManagedApp2, }, - // Test case with failing request to LH, the subsequent successful request should contain the total count since initial failure { name: "WithUsageWithFailure", loopCount: 3, - retryBatchCount: 0, apiTransactionCount: []int{5, 10, 12}, failUsageEventOnServer: []bool{false, true, false, false}, failUsageResponseOnServer: []*UsageResponse{ - nil, { - Description: "Regular failure", - StatusCode: 400, - Success: false, - }, + nil, + {Description: "Regular failure", StatusCode: 400, Success: false}, nil, nil, }, @@ -414,24 +408,14 @@ func TestMetricCollector(t *testing.T) { expectedMetricEventsAcked: 1, appName: "unknown", }, - // Test case with failing request to LH, no subsequent request triggered. { name: "WithUsageWithFailureWithSpecificDescription", loopCount: 3, - retryBatchCount: 0, apiTransactionCount: []int{1, 1, 1}, failUsageEventOnServer: []bool{true, true, false}, failUsageResponseOnServer: []*UsageResponse{ - { - Description: "The file exceeds the maximum upload size of 454545", - StatusCode: 400, - Success: false, - }, - { - Description: "Environment ID not found", - StatusCode: 404, - Success: false, - }, + {Description: "The file exceeds the maximum upload size of 454545", StatusCode: 400, Success: false}, + {Description: "Environment ID not found", StatusCode: 404, Success: false}, nil, }, expectedLHEvents: []int{0, 0, 1}, @@ -441,7 +425,6 @@ func TestMetricCollector(t *testing.T) { expectedMetricEventsAcked: 1, appName: "unknown", }, - // Retry limit hit { name: "WithUsageAndFailedMetric", loopCount: 1, @@ -451,30 +434,74 @@ func TestMetricCollector(t *testing.T) { failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{1}, expectedTransactionCount: []int{5}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 0, }, - // Traceability healthcheck failing, nothing reported { name: "WithUsageTraceabilityNotConnected", loopCount: 1, - retryBatchCount: 0, apiTransactionCount: []int{0}, failUsageEventOnServer: []bool{false}, failUsageResponseOnServer: []*UsageResponse{nil}, expectedLHEvents: []int{0}, expectedTransactionCount: []int{0}, - trackVolume: false, expectedTransactionVolume: []int{0}, - expectedMetricEventsAcked: 0, // API metric + Provider subscription metric - appName: "managed-app-1", + appName: testManagedApp1, hcStatus: healthcheck.FAIL, skipWaitForPub: true, }, } +} - for _, test := range testCases { +func setupMetricCollectorTest(t *testing.T, s *testHTTPServer) (*collector, *config.CentralConfiguration) { + t.Helper() + cfg := createCentralCfg(s.server.URL, "demo") + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse + cfg.MetricReporting.(*config.MetricReportingConfiguration).Publish = true + cfg.SetEnvironmentID(testEnvID) + cmd.BuildDataPlaneType = "Azure" + agent.Initialize(cfg) + + cm := agent.GetCacheManager() + cm.AddAPIServiceInstance(createAPIServiceInstance(testInstID, testInstName, "111")) + cm.AddManagedApplication(createManagedApplication("app-1", testManagedApp1, "")) + cm.AddManagedApplication(createManagedApplication("app-2", testManagedApp2, testConsumerOrg)) + cm.AddAccessRequest(createAccessRequest("ac-1", testAccessReq1, testManagedApp1, testInstID, testInstName, testSubscription1)) + cm.AddAccessRequest(createAccessRequest("ac-2", testAccessReq2, testManagedApp2, testInstID, testInstName, testSubscription2)) + + return createMetricCollector().(*collector), cfg +} + +func runMetricCollectorLoop(s *testHTTPServer, metricCollector *collector, test metricCollectorTestCase, l int) { + for i := 0; i < test.apiTransactionCount[l]; i++ { + metricCollector.AddMetricDetail(Detail{ + APIDetails: apiDetails1, + StatusCode: "200", + Duration: 10, + Bytes: 10, + AppDetails: models.AppDetails{ID: "111", Name: test.appName}, + }) + } + s.failUsageEvent = test.failUsageEventOnServer[l] + s.failUsageResponse = test.failUsageResponseOnServer[l] + if test.publishPrior { + metricCollector.usagePublisher.Execute() + metricCollector.Execute() + } else { + metricCollector.Execute() + metricCollector.usagePublisher.Execute() + } +} + +func TestMetricCollector(t *testing.T) { + defer cleanUpCachedMetricFile() + s := &testHTTPServer{} + defer s.closeServer() + s.startServer() + traceability.SetDataDirPath(".") + + metricCollector, cfg := setupMetricCollectorTest(t, s) + + for _, test := range metricCollectorTestCases() { t.Run(test.name, func(t *testing.T) { if test.hcStatus != "" { traceStatus = test.hcStatus @@ -483,34 +510,12 @@ func TestMetricCollector(t *testing.T) { metricCollector.metricMap = make(map[string]map[string]map[string]map[string]*centralMetric) cfg.SetAxwayManaged(test.trackVolume) testClient := setupMockClient(test.retryBatchCount) - mockClient := testClient.(*MockClient) for l := 0; l < test.loopCount; l++ { - fmt.Printf("\n\nTransaction Info: %+v\n\n", test.apiTransactionCount[l]) - for i := 0; i < test.apiTransactionCount[l]; i++ { - metricDetail := Detail{ - APIDetails: apiDetails1, - StatusCode: "200", - Duration: 10, - Bytes: 10, - AppDetails: models.AppDetails{ - ID: "111", - Name: test.appName, - }, - } - metricCollector.AddMetricDetail(metricDetail) - } - s.failUsageEvent = test.failUsageEventOnServer[l] - s.failUsageResponse = test.failUsageResponseOnServer[l] - if test.publishPrior { - metricCollector.usagePublisher.Execute() - metricCollector.Execute() - } else { - metricCollector.Execute() - metricCollector.usagePublisher.Execute() - } + runMetricCollectorLoop(s, metricCollector, test, l) } - assert.Equal(t, test.expectedMetricEventsAcked, mockClient.eventsAcked) + + assert.Equal(t, test.expectedMetricEventsAcked, testClient.(*MockClient).eventsAcked) s.resetConfig() }) } @@ -525,9 +530,9 @@ func TestConcurrentMetricCollectorEvents(t *testing.T) { traceability.SetDataDirPath(".") cfg := createCentralCfg(s.server.URL, "demo") - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse cfg.MetricReporting.(*config.MetricReportingConfiguration).Publish = true - cfg.SetEnvironmentID("267bd671-e5e2-4679-bcc3-bbe7b70f30fd") + cfg.SetEnvironmentID(testEnvID) cmd.BuildDataPlaneType = "Azure" agent.Initialize(cfg) myCollector := createMetricCollector() @@ -598,21 +603,21 @@ func TestMetricCollectorUsageAggregation(t *testing.T) { traceability.SetDataDirPath(".") cfg := createCentralCfg(s.server.URL, "demo") - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse cfg.MetricReporting.(*config.MetricReportingConfiguration).Publish = true - cfg.SetEnvironmentID("267bd671-e5e2-4679-bcc3-bbe7b70f30fd") + cfg.SetEnvironmentID(testEnvID) cmd.BuildDataPlaneType = "Azure" agent.Initialize(cfg) // setup the cache for handling custom metrics cm := agent.GetCacheManager() - cm.AddAPIServiceInstance(createAPIServiceInstance("inst-1", "instance-1", "111")) + cm.AddAPIServiceInstance(createAPIServiceInstance(testInstID, testInstName, "111")) - cm.AddManagedApplication(createManagedApplication("app-1", "managed-app-1", "")) - cm.AddManagedApplication(createManagedApplication("app-2", "managed-app-2", "test-consumer-org")) + cm.AddManagedApplication(createManagedApplication("app-1", testManagedApp1, "")) + cm.AddManagedApplication(createManagedApplication("app-2", testManagedApp2, testConsumerOrg)) - cm.AddAccessRequest(createAccessRequest("ac-1", "access-req-1", "managed-app-1", "inst-1", "instance-1", "subscription-1")) - cm.AddAccessRequest(createAccessRequest("ac-2", "access-req-2", "managed-app-2", "inst-1", "instance-1", "subscription-2")) + cm.AddAccessRequest(createAccessRequest("ac-1", testAccessReq1, testManagedApp1, testInstID, testInstName, testSubscription1)) + cm.AddAccessRequest(createAccessRequest("ac-2", testAccessReq2, testManagedApp2, testInstID, testInstName, testSubscription2)) traceStatus = healthcheck.OK runTestHealthcheck() @@ -651,7 +656,7 @@ func TestMetricCollectorUsageAggregation(t *testing.T) { setupMockClient(0) myCollector := createMetricCollector() metricCollector := myCollector.(*collector) - metricCollector.usagePublisher.schedule = "* * * * *" + metricCollector.usagePublisher.schedule = testCronSchedule metricCollector.usagePublisher.report.currTimeFunc = getFutureTime mockReports := generateMockReports(test.transactionsPerReport) @@ -698,8 +703,8 @@ func TestMetricCollectorCache(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { cfg := createCentralCfg(s.server.URL, "demo") - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" - cfg.SetEnvironmentID("267bd671-e5e2-4679-bcc3-bbe7b70f30fd") + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse + cfg.SetEnvironmentID(testEnvID) cfg.SetAxwayManaged(test.trackVolume) cmd.BuildDataPlaneType = "Azure" agent.Initialize(cfg) @@ -707,7 +712,7 @@ func TestMetricCollectorCache(t *testing.T) { traceability.SetDataDirPath(".") myCollector := createMetricCollector() metricCollector := myCollector.(*collector) - metricCollector.usagePublisher.schedule = "* * * * *" + metricCollector.usagePublisher.schedule = testCronSchedule metricCollector.usagePublisher.report.currTimeFunc = getFutureTime metricCollector.AddMetric(apiDetails1, "200", 5, 10, "") @@ -731,7 +736,7 @@ func TestMetricCollectorCache(t *testing.T) { // Recreate the collector that loads the stored metrics, so 3 transactions myCollector = createMetricCollector() metricCollector = myCollector.(*collector) - metricCollector.usagePublisher.schedule = "* * * * *" + metricCollector.usagePublisher.schedule = testCronSchedule metricCollector.usagePublisher.report.currTimeFunc = getFutureTime metricCollector.AddMetric(apiDetails1, "200", 5, 10, "") @@ -753,7 +758,7 @@ func TestMetricCollectorCache(t *testing.T) { // Recreate the collector that loads the stored metrics, 0 transactions myCollector = createMetricCollector() metricCollector = myCollector.(*collector) - metricCollector.usagePublisher.schedule = "* * * * *" + metricCollector.usagePublisher.schedule = testCronSchedule metricCollector.usagePublisher.report.currTimeFunc = getFutureTime metricCollector.Execute() @@ -767,131 +772,116 @@ func TestMetricCollectorCache(t *testing.T) { } } -func TestOfflineMetricCollector(t *testing.T) { - defer cleanUpCachedMetricFile() - s := &testHTTPServer{} - defer s.closeServer() - s.startServer() - traceability.SetDataDirPath(".") +type offlineMetricTestCase struct { + name string + loopCount int + apiTransactionCount []int +} - traceStatus = healthcheck.OK - runTestHealthcheck() +func offlineMetricTestCases() []offlineMetricTestCase { + return []offlineMetricTestCase{ + {name: "NoReports", loopCount: 0, apiTransactionCount: []int{}}, + {name: "OneReport", loopCount: 1, apiTransactionCount: []int{10}}, + {name: "ThreeReports", loopCount: 3, apiTransactionCount: []int{5, 10, 2}}, + {name: "ThreeReportsNoUsage", loopCount: 3, apiTransactionCount: []int{0, 0, 0}}, + {name: "SixReports", loopCount: 6, apiTransactionCount: []int{5, 10, 2, 0, 3, 9}}, + } +} +func setupOfflineCollectorCfg(t *testing.T, s *testHTTPServer) *config.CentralConfiguration { + t.Helper() cfg := createCentralCfg(s.server.URL, "demo") - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" - cfg.EnvironmentID = "267bd671-e5e2-4679-bcc3-bbe7b70f30fd" + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse + cfg.EnvironmentID = testEnvID cmd.BuildDataPlaneType = "Azure" - usgCfg := cfg.UsageReporting.(*config.UsageReportingConfiguration) - usgCfg.Offline = true + cfg.UsageReporting.(*config.UsageReportingConfiguration).Offline = true agent.Initialize(cfg) + return cfg +} - testCases := []struct { - name string - loopCount int - apiTransactionCount []int - generateReport bool - }{ - { - name: "NoReports", - loopCount: 0, - apiTransactionCount: []int{}, - generateReport: true, - }, - { - name: "OneReport", - loopCount: 1, - apiTransactionCount: []int{10}, - generateReport: true, - }, - { - name: "ThreeReports", - loopCount: 3, - apiTransactionCount: []int{5, 10, 2}, - generateReport: true, - }, - { - name: "ThreeReportsNoUsage", - loopCount: 3, - apiTransactionCount: []int{0, 0, 0}, - generateReport: true, - }, - { - name: "SixReports", - loopCount: 6, - apiTransactionCount: []int{5, 10, 2, 0, 3, 9}, - generateReport: true, - }, +func validateOfflineEvents(t *testing.T, cfg *config.CentralConfiguration, s *testHTTPServer, report UsageEvent, test offlineMetricTestCase, startDate time.Time) { + t.Helper() + for j := 0; j < test.loopCount; j++ { + reportKey := startDate.Add(time.Duration(j-1) * time.Hour).Format(ISO8601) + assert.Equal(t, cmd.BuildDataPlaneType, report.Report[reportKey].Product) + assert.Equal(t, test.apiTransactionCount[j], int(report.Report[reportKey].Usage[cmd.BuildDataPlaneType+".Transactions"])) } + if test.loopCount == 0 { + return + } + assert.Equal(t, int(time.Hour.Milliseconds()), report.Granularity) + cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + testLighthouse + assert.Equal(t, cfg.UsageReporting.GetURL()+schemaPath, report.SchemaID) + assert.Equal(t, cfg.GetEnvironmentID(), report.EnvID) +} - for testNum, test := range testCases { - t.Run(test.name, func(t *testing.T) { - startDate := time.Date(2021, 1, 31, 12, 30, 0, 0, time.Local) - setupMockClient(0) - testLoops := 0 - now = func() time.Time { - next := startDate.Add(time.Hour * time.Duration(testLoops)) - fmt.Println(next.Format(ISO8601)) - return next - } +func runOfflineCollectorLoops(myCollector Collector, test offlineMetricTestCase, startDate time.Time) { + metricCollector := myCollector.(*collector) + testLoops := 0 + now = func() time.Time { + next := startDate.Add(time.Hour * time.Duration(testLoops)) + return next + } + for testLoops < test.loopCount { + for i := 0; i < test.apiTransactionCount[testLoops]; i++ { + myCollector.AddMetric(apiDetails1, "200", 10, 10, "") + } + metricCollector.Execute() + testLoops++ + } +} - validateEvents := func(report UsageEvent) { - for j := 0; j < test.loopCount; j++ { - reportKey := startDate.Add(time.Duration(j-1) * time.Hour).Format(ISO8601) - assert.Equal(t, cmd.BuildDataPlaneType, report.Report[reportKey].Product) - assert.Equal(t, test.apiTransactionCount[j], int(report.Report[reportKey].Usage[cmd.BuildDataPlaneType+".Transactions"])) - } - // validate granularity when reports not empty - if test.loopCount != 0 { - assert.Equal(t, int(time.Hour.Milliseconds()), report.Granularity) - cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/lighthouse" - assert.Equal(t, cfg.UsageReporting.GetURL()+schemaPath, report.SchemaID) - assert.Equal(t, cfg.GetEnvironmentID(), report.EnvID) - } - } +func validateOfflineReportFile(t *testing.T, cfg *config.CentralConfiguration, s *testHTTPServer, myCollector Collector, test offlineMetricTestCase, startDate time.Time, testNum int) { + t.Helper() + metricCollector := myCollector.(*collector) + reportGenerator := metricCollector.reports + publisher := metricCollector.usagePublisher - myCollector := createMetricCollector() - metricCollector := myCollector.(*collector) + events := metricCollector.reports.loadEvents() + validateOfflineEvents(t, cfg, s, events, test, startDate) - reportGenerator := metricCollector.reports - publisher := metricCollector.usagePublisher - for testLoops < test.loopCount { - for i := 0; i < test.apiTransactionCount[testLoops]; i++ { - myCollector.AddMetric(apiDetails1, "200", 10, 10, "") - } - metricCollector.Execute() - testLoops++ - } + publisher.Execute() - // Get the usage reports from the cache and validate - events := myCollector.(*collector).reports.loadEvents() - validateEvents(events) + if test.loopCount == 0 { + expectedFile := reportGenerator.generateReportPath(ISO8601Time(startDate), 0) + assert.NoFileExists(t, expectedFile) + return + } - // generate the report file - publisher.Execute() + expectedFile := reportGenerator.generateReportPath(ISO8601Time(startDate), testNum-1) + assert.FileExists(t, expectedFile) - expectedFile := reportGenerator.generateReportPath(ISO8601Time(startDate), testNum-1) - if test.loopCount == 0 { - // no report expected, end the test here - expectedFile = reportGenerator.generateReportPath(ISO8601Time(startDate), 0) - assert.NoFileExists(t, expectedFile) - return - } + data, err := os.ReadFile(expectedFile) + assert.Nil(t, err) + + var reportEvents UsageEvent + err = json.Unmarshal(data, &reportEvents) + assert.Nil(t, err) + assert.NotNil(t, reportEvents) + + validateOfflineEvents(t, cfg, s, reportEvents, test, startDate) + s.resetOffline(myCollector) +} - // validate the file exists and open it - assert.FileExists(t, expectedFile) - data, err := os.ReadFile(expectedFile) - assert.Nil(t, err) +func TestOfflineMetricCollector(t *testing.T) { + defer cleanUpCachedMetricFile() + s := &testHTTPServer{} + defer s.closeServer() + s.startServer() + traceability.SetDataDirPath(".") + traceStatus = healthcheck.OK + runTestHealthcheck() - // unmarshall it - var reportEvents UsageEvent - err = json.Unmarshal(data, &reportEvents) - assert.Nil(t, err) - assert.NotNil(t, reportEvents) + cfg := setupOfflineCollectorCfg(t, s) - // validate event in generated reports - validateEvents(reportEvents) + for testNum, test := range offlineMetricTestCases() { + t.Run(test.name, func(t *testing.T) { + setupMockClient(0) + startDate := time.Date(2021, 1, 31, 12, 30, 0, 0, time.Local) + myCollector := createMetricCollector() - s.resetOffline(myCollector) + runOfflineCollectorLoops(myCollector, test, startDate) + validateOfflineReportFile(t, cfg, s, myCollector, test, startDate, testNum) }) } cleanUpReportFiles() @@ -909,18 +899,18 @@ func TestCustomMetrics(t *testing.T) { cfg := createCentralCfg(s.server.URL, "demo") cfg.UsageReporting.(*config.UsageReportingConfiguration).URL = s.server.URL + "/usage" - cfg.SetEnvironmentID("267bd671-e5e2-4679-bcc3-bbe7b70f30fd") + cfg.SetEnvironmentID(testEnvID) cmd.BuildDataPlaneType = "Azure" agent.Initialize(cfg) cm := agent.GetCacheManager() - cm.AddAPIServiceInstance(createAPIServiceInstance("inst-1", "instance-1", "111")) + cm.AddAPIServiceInstance(createAPIServiceInstance(testInstID, testInstName, "111")) - cm.AddManagedApplication(createManagedApplication("app-1", "managed-app-1", "")) - cm.AddManagedApplication(createManagedApplication("app-2", "managed-app-2", "test-consumer-org")) + cm.AddManagedApplication(createManagedApplication("app-1", testManagedApp1, "")) + cm.AddManagedApplication(createManagedApplication("app-2", testManagedApp2, testConsumerOrg)) - cm.AddAccessRequest(createAccessRequest("ac-1", "access-req-1", "managed-app-1", "inst-1", "instance-1", "subscription-1")) - cm.AddAccessRequest(createAccessRequest("ac-2", "access-req-2", "managed-app-2", "inst-1", "instance-1", "subscription-2")) + cm.AddAccessRequest(createAccessRequest("ac-1", testAccessReq1, testManagedApp1, testInstID, testInstName, testSubscription1)) + cm.AddAccessRequest(createAccessRequest("ac-2", testAccessReq2, testManagedApp2, testInstID, testInstName, testSubscription2)) myCollector := createMetricCollector() metricCollector := myCollector.(*collector) @@ -985,7 +975,7 @@ func TestCustomMetrics(t *testing.T) { } } -func TestCollector_CreateOrUpdateHistogram_IDResolution(t *testing.T) { +func TestCollectorCreateOrUpdateHistogramIDResolution(t *testing.T) { // Mock setup would go here - this is a conceptual test tests := []struct { name string @@ -1110,3 +1100,239 @@ func TestBuildDurations(t *testing.T) { }) } } + +// noopStorage satisfies the storageCache interface with no-ops for all methods except +// removeMetric, which records calls for assertion in cleanup tests. +type noopStorage struct { + removed []*centralMetric +} + +func (s *noopStorage) initialize() { /* no-op */ } +func (s *noopStorage) updateUsage(_ int) { /* no-op */ } +func (s *noopStorage) updateVolume(_ int64) { /* no-op */ } +func (s *noopStorage) updateAppUsage(_ int, _ string) { /* no-op */ } +func (s *noopStorage) updateMetric(_ cachedMetricInterface, _ *centralMetric) { /* no-op */ } +func (s *noopStorage) save() { /* no-op */ } +func (s *noopStorage) removeMetric(m *centralMetric) { s.removed = append(s.removed, m) } + +func newCleanupCollector(metricMap map[string]map[string]map[string]map[string]*centralMetric) (*collector, *noopStorage) { + st := &noopStorage{} + c := &collector{ + metricMap: metricMap, + metricMapLock: &sync.Mutex{}, + storage: st, + } + return c, st +} + +func TestRemoveMetricEntries(t *testing.T) { + metric1 := ¢ralMetric{EventID: "m1"} + metric2 := ¢ralMetric{EventID: "m2"} + counter1 := ¢ralMetric{EventID: "c1"} + + tests := map[string]struct { + metricMap map[string]map[string]map[string]map[string]*centralMetric + subID string + appID string + apiID string + group string + counters map[string]metrics.Counter + wantRemoved int + wantGroupGone bool + wantCounterKey string + }{ + "removes group entry and clears histogram": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"Success": metric1}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", group: "Success", + counters: map[string]metrics.Counter{}, + wantRemoved: 1, + wantGroupGone: true, + }, + "removes counter entries alongside group": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"Success": metric1, "ctr": counter1}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", group: "Success", + counters: map[string]metrics.Counter{"ctr": metrics.NewCounter()}, + wantRemoved: 2, + wantGroupGone: true, + wantCounterKey: "ctr", + }, + "missing apiID is a no-op": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", group: "Success", + counters: map[string]metrics.Counter{}, + wantRemoved: 0, + }, + "group key absent leaves counters still cleaned": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"ctr": counter1}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", group: "Missing", + counters: map[string]metrics.Counter{"ctr": metrics.NewCounter()}, + wantRemoved: 1, + wantGroupGone: false, + wantCounterKey: "ctr", + }, + "counter key absent from apiStatusMap does not panic": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"Success": metric2}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", group: "Success", + counters: map[string]metrics.Counter{"ghost": metrics.NewCounter()}, + wantRemoved: 1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c, st := newCleanupCollector(tc.metricMap) + hist := metrics.NewHistogram(metrics.NewUniformSample(8)) + hist.Update(42) + + c.removeMetricEntries(tc.subID, tc.appID, tc.apiID, tc.group, hist, tc.counters) + + assert.Len(t, st.removed, tc.wantRemoved) + if tc.wantGroupGone { + apiMap, ok := tc.metricMap[tc.subID][tc.appID][tc.apiID] + if ok { + _, present := apiMap[tc.group] + assert.False(t, present, "group key should have been deleted") + } + } + if tc.wantCounterKey != "" { + _, present := tc.counters[tc.wantCounterKey] + assert.False(t, present, "counter key should have been deleted from counters map") + } + }) + } +} + +func TestPruneEmptyMapLevels(t *testing.T) { + tests := map[string]struct { + metricMap map[string]map[string]map[string]map[string]*centralMetric + subID string + appID string + apiID string + wantAPIGone bool + wantAppGone bool + wantSubGone bool + preservedAppID string // when set, asserts this appID is still present after the call + }{ + "non-empty apiID level is not pruned": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"Success": ¢ralMetric{}}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", + wantAPIGone: false, wantAppGone: false, wantSubGone: false, + }, + "empty apiID level is pruned, non-empty app level kept": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {}, "api2": {"Success": ¢ralMetric{}}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", + wantAPIGone: true, wantAppGone: false, wantSubGone: false, + }, + "empty apiID and app levels pruned, non-empty sub level kept": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": { + "app1": {"api1": {}}, + "app2": {"api2": {"Success": ¢ralMetric{}}}, + }, + }, + subID: "sub1", appID: "app1", apiID: "api1", + wantAPIGone: true, wantAppGone: true, wantSubGone: false, + }, + "all levels empty are fully pruned": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {}}}, + }, + subID: "sub1", appID: "app1", apiID: "api1", + wantAPIGone: true, wantAppGone: true, wantSubGone: true, + }, + "absent appID within existing subID is a no-op for subID": { + metricMap: map[string]map[string]map[string]map[string]*centralMetric{ + "sub1": {"app1": {"api1": {"Success": ¢ralMetric{}}}}, + }, + subID: "sub1", appID: "missing-app", apiID: "api1", + wantAPIGone: true, wantAppGone: true, wantSubGone: false, + preservedAppID: "app1", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c, _ := newCleanupCollector(tc.metricMap) + + c.removeEmptyKeys(tc.subID, tc.appID, tc.apiID) + + _, apiPresent := tc.metricMap[tc.subID][tc.appID][tc.apiID] + assert.Equal(t, tc.wantAPIGone, !apiPresent, "apiID level presence mismatch") + + _, appPresent := tc.metricMap[tc.subID][tc.appID] + assert.Equal(t, tc.wantAppGone, !appPresent, "appID level presence mismatch") + + _, subPresent := tc.metricMap[tc.subID] + assert.Equal(t, tc.wantSubGone, !subPresent, "subID level presence mismatch") + + if tc.preservedAppID != "" { + _, preserved := tc.metricMap[tc.subID][tc.preservedAppID] + assert.True(t, preserved, "app %q should still be present after no-op call", tc.preservedAppID) + } + }) + } +} + +// buildTestJWT creates a minimal JWT with the given claims. The signature is +// not verified by GetOrgGUID (uses ParseUnverified), so a placeholder is fine. +func buildTestJWT(claims map[string]interface{}) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"HS256"}`)) + payload, _ := json.Marshal(claims) + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".fakesig" +} + +func TestGetOrgGUID(t *testing.T) { + const wantOrgGUID = "1234-1234-1234-1234" // matches org_guid in accessToken + + cases := map[string]struct { + setupToken string // token returned by mock auth server; empty = InitializeForTest(nil) + wantGUID string + }{ + "valid token with org_guid returns GUID": { + setupToken: accessToken, + wantGUID: wantOrgGUID, + }, + "no auth token returns empty string": { + wantGUID: "", + }, + "malformed token returns empty string": { + setupToken: "not-a-jwt", + wantGUID: "", + }, + "valid JWT without org_guid claim returns empty string": { + setupToken: buildTestJWT(map[string]interface{}{"sub": "test-user", "iss": "test"}), + wantGUID: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + token := tc.setupToken + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Write([]byte(`{"access_token":"` + token + `","expires_in":3600}`)) + })) + defer srv.Close() + cfg := createCentralCfg(srv.URL, "test-env") + agent.Initialize(cfg) + assert.Equal(t, tc.wantGUID, GetOrgGUID()) + }) + } +} diff --git a/pkg/transaction/metric/units.go b/pkg/transaction/metric/units.go index fc46c56c7..d1f8dde5d 100644 --- a/pkg/transaction/metric/units.go +++ b/pkg/transaction/metric/units.go @@ -13,6 +13,7 @@ type UnitCount struct { type Transactions struct { UnitCount + Duration int64 `json:"duration,omitempty"` Response *ResponseMetrics `json:"response,omitempty"` Status string `json:"status,omitempty"` } diff --git a/pkg/transaction/metric/util.go b/pkg/transaction/metric/util.go index 78f9c296a..584ccc4c2 100644 --- a/pkg/transaction/metric/util.go +++ b/pkg/transaction/metric/util.go @@ -15,112 +15,156 @@ func centralMetricFromAPIMetric(in *APIMetric) *centralMetric { return nil } + apicDeployment, agentName, runtimeType := centralConfigFields() + out := ¢ralMetric{ - EventID: in.EventID, - Observation: &models.ObservationDetails{ - Start: in.Observation.Start, - }, + Version: metricDataVersion, + APICDeployment: apicDeployment, + Environment: &EnvironmentInfo{RuntimeType: runtimeType}, + EventID: in.EventID, + Observation: &models.ObservationDetails{Start: in.Observation.Start, End: in.Observation.End}, Reporter: &Reporter{ AgentVersion: cmd.BuildVersion, AgentType: cmd.BuildAgentName, AgentSDKVersion: cmd.SDKBuildVersion, - AgentName: agent.GetCentralConfig().GetAgentName(), + AgentName: agentName, ObservationDelta: in.Observation.End - in.Observation.Start, }, } - if in.Unit == nil { - status := in.Status - if status == "" { - status = sampling.GetStatusFromCodeString(in.StatusCode).String() - } - // transaction units - out.Units = &Units{ - Transactions: &Transactions{ - UnitCount: UnitCount{ - Count: in.Count, - }, - Status: status, - Response: &ResponseMetrics{ - Max: in.Response.Max, - Min: in.Response.Min, - Avg: in.Response.Avg, - }, - }, - } - if in.Quota.ID != unknown && in.Quota.ID != "" { - out.Units.Transactions.Quota = &models.ResourceReference{ - ID: in.Quota.ID, - } - } - } else { - // custom units - out.Units = &Units{ - CustomUnits: map[string]*UnitCount{ - in.Unit.Name: { - Count: in.Count, - }, - }, - } - if in.Quota.ID != unknown && in.Quota.ID != "" { - out.Units.CustomUnits[in.Unit.Name].Quota = &models.ResourceReference{ - ID: in.Quota.ID, - } - } - } + out.Units = buildUnits(in) - if in.Subscription.ID != unknown && in.Subscription.ID != "" { - out.Subscription = &models.ResourceReference{ - ID: in.Subscription.ID, - } + if id := in.Subscription.ID; isKnownID(id) { + out.Subscription = &models.ResourceReference{ID: id} } - if in.App.ID != unknown && in.App.ID != "" { - out.App = &models.ApplicationResourceReference{ - ResourceReference: models.ResourceReference{ - ID: in.App.ID, - }, - ConsumerOrgID: in.App.ConsumerOrgID, - } + if isKnownID(in.App.ID) { + out.App = buildAppRef(in.App) } - if in.Product.ID != unknown && in.Product.ID != "" { + if id := in.Product.ID; isKnownID(id) { out.Product = &models.ProductResourceReference{ - ResourceReference: models.ResourceReference{ - ID: in.Product.ID, - }, - VersionID: in.Product.VersionID, + ResourceReference: models.ResourceReference{ID: id}, + VersionID: in.Product.VersionID, + Owner: in.Product.Owner, } } - if in.API.ID != unknown && in.API.ID != "" { - out.API = &models.APIResourceReference{ - ResourceReference: models.ResourceReference{ - ID: in.API.ID, - }, - Name: in.API.Name, - } - svc := agent.GetCacheManager().GetAPIServiceWithAPIID(strings.TrimPrefix(in.API.ID, transutil.SummaryEventProxyIDPrefix)) - if svc != nil { - out.API.APIServiceID = svc.Metadata.ID - } + if isKnownID(in.API.ID) { + out.API = buildAPIRef(in.API) } - if in.AssetResource.ID != unknown && in.AssetResource.ID != "" { - out.AssetResource = &models.ResourceReference{ - ID: in.AssetResource.ID, - } + if id := in.AssetResource.ID; isKnownID(id) { + out.AssetResource = &models.ResourceReference{ID: id} } - if in.ProductPlan.ID != unknown && in.ProductPlan.ID != "" { - out.ProductPlan = &models.ResourceReference{ - ID: in.ProductPlan.ID, - } + if isKnownID(in.APIServiceRevisionID) { + out.APIServiceRevision = &models.ResourceReference{ID: in.APIServiceRevisionID} + } + + if id := in.ProductPlan.ID; isKnownID(id) { + out.ProductPlan = &models.ResourceReference{ID: id} } return out } +func centralConfigFields() (apicDeployment, agentName, runtimeType string) { + runtimeType = unknown + cfg := agent.GetCentralConfig() + if cfg == nil { + return + } + if cfg.IsAxwayManaged() { + runtimeType = runtimeTypeManaged + } else { + runtimeType = runtimeTypeUnmanaged + } + apicDeployment = cfg.GetAPICDeployment() + agentName = cfg.GetAgentName() + return +} + +func isKnownID(id string) bool { + return id != "" && id != unknown +} + +func buildUnits(in *APIMetric) *Units { + if in.Unit != nil { + return buildCustomUnits(in) + } + return buildTransactionUnits(in) +} + +func buildTransactionUnits(in *APIMetric) *Units { + status := in.Status + if status == "" { + status = sampling.GetStatusFromCodeString(in.StatusCode).String() + } + txn := &Transactions{ + UnitCount: UnitCount{Count: in.Count}, + Duration: int64(in.Response.Avg * float64(in.Count)), + Status: status, + Response: &ResponseMetrics{ + Max: in.Response.Max, + Min: in.Response.Min, + Avg: in.Response.Avg, + }, + } + if isKnownID(in.Quota.ID) { + txn.Quota = &models.ResourceReference{ID: in.Quota.ID} + } + return &Units{Transactions: txn} +} + +func buildCustomUnits(in *APIMetric) *Units { + uc := &UnitCount{Count: in.Count} + if isKnownID(in.Quota.ID) { + uc.Quota = &models.ResourceReference{ID: in.Quota.ID} + } + return &Units{ + CustomUnits: map[string]*UnitCount{in.Unit.Name: uc}, + } +} + +func buildAppRef(app models.AppDetails) *models.ApplicationResourceReference { + ref := &models.ApplicationResourceReference{ + ResourceReference: models.ResourceReference{ID: app.ID}, + ConsumerOrgID: app.ConsumerOrgID, + } + ref.Owner = resolveAppOwnerFromCache(app.ID) + return ref +} + +func resolveAppOwnerFromCache(appID string) *models.Owner { + cacheManager := agent.GetCacheManager() + if cacheManager == nil { + return &models.Owner{Type: unknown} + } + managedApp := cacheManager.GetManagedApplicationByApplicationID(appID) + if managedApp == nil { + managedApp = cacheManager.GetManagedApplication(appID) + } + if managedApp != nil { + return transutil.ResolveAppOwnerFromManagedApp(managedApp) + } + return &models.Owner{Type: unknown} +} + +func buildAPIRef(api models.APIDetails) *models.APIResourceReference { + ref := &models.APIResourceReference{ + ResourceReference: models.ResourceReference{ID: api.ID}, + Name: api.Name, + } + cacheManager := agent.GetCacheManager() + stripped := strings.TrimPrefix(api.ID, transutil.SummaryEventProxyIDPrefix) + if svc := cacheManager.GetAPIServiceWithAPIID(stripped); svc != nil { + ref.APIServiceID = svc.Metadata.ID + } + ref.Owner = transutil.ResolveAPIOwner(api.ID, cacheManager) + return ref +} + func splitMetricKey(key string) (string, string) { const delimiter = "." diff --git a/pkg/transaction/metric/util_test.go b/pkg/transaction/metric/util_test.go index 4cfedcea7..d22c6c495 100644 --- a/pkg/transaction/metric/util_test.go +++ b/pkg/transaction/metric/util_test.go @@ -3,21 +3,40 @@ package metric import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/Axway/agent-sdk/pkg/agent" + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" "github.com/Axway/agent-sdk/pkg/cmd" "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/transaction/models" - "github.com/stretchr/testify/assert" ) +const ( + testTeamGUID1 = "team-guid-1" + testAPIEmptyGUID = "api-empty-guid" + testAppEmptyGUID = "app-empty-guid" +) + +func makeAPIServiceRI(apiID string, owner *v1.Owner) *v1.ResourceInstance { + svc := management.NewAPIService("svc-"+apiID, "env1") + svc.SubResources = map[string]any{ + "x-agent-details": map[string]any{"externalAPIID": apiID}, + } + svc.Owner = owner + ri, _ := svc.AsInstance() + return ri +} + func TestCentralMetricFromAPIMetric(t *testing.T) { - agent.InitializeForTest( - nil, - agent.TestWithCentralConfig(&config.CentralConfiguration{AgentName: "agent"}), - ) + testCfg := &config.CentralConfiguration{AgentName: "agent"} + agent.InitializeForTest(nil, agent.TestWithCentralConfig(testCfg)) testCases := map[string]struct { skip bool + setupCache func() input *APIMetric expectedOutput *centralMetric }{ @@ -41,9 +60,12 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { }, }, expectedOutput: ¢ralMetric{ - EventID: "id-1", + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-1", Observation: &models.ObservationDetails{ Start: 10, + End: 15, }, Reporter: &Reporter{ AgentVersion: cmd.BuildVersion, @@ -60,6 +82,7 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { ID: "quota", }, }, + Duration: 5000, Response: &ResponseMetrics{ Max: 100, Min: 10, @@ -113,7 +136,9 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { }, }, expectedOutput: ¢ralMetric{ - EventID: "id-1", + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-1", Reporter: &Reporter{ AgentVersion: cmd.BuildVersion, AgentType: cmd.BuildAgentName, @@ -123,6 +148,7 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { }, Observation: &models.ObservationDetails{ Start: 10, + End: 15, }, Units: &Units{ CustomUnits: map[string]*UnitCount{ @@ -146,6 +172,7 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { }, Name: "api", APIServiceID: "", + Owner: &models.Owner{Type: "unknown"}, }, Product: &models.ProductResourceReference{ ResourceReference: models.ResourceReference{ @@ -158,20 +185,348 @@ func TestCentralMetricFromAPIMetric(t *testing.T) { ID: "app", }, ConsumerOrgID: "org", + Owner: &models.Owner{Type: "unknown"}, }, Subscription: &models.ResourceReference{ ID: "sub", }, }, }, + "product owner nil when not set": { + input: &APIMetric{ + EventID: "id-no-owner", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + Product: models.Product{ + ID: "prod-no-owner", + VersionID: "ver-1", + }, + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-no-owner", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + Product: &models.ProductResourceReference{ + ResourceReference: models.ResourceReference{ID: "prod-no-owner"}, + VersionID: "ver-1", + Owner: nil, + }, + }, + }, + "product owner propagated when set": { + input: &APIMetric{ + EventID: "id-with-owner", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + Product: models.Product{ + ID: "prod-with-owner", + VersionID: "ver-2", + Owner: &models.Owner{Type: "team", TeamGUID: testTeamGUID1}, + }, + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-with-owner", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + Product: &models.ProductResourceReference{ + ResourceReference: models.ResourceReference{ID: "prod-with-owner"}, + VersionID: "ver-2", + Owner: &models.Owner{Type: "team", TeamGUID: testTeamGUID1}, + }, + }, + }, + "api service revision nil when not set": { + input: &APIMetric{ + EventID: "id-no-revision", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-no-revision", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + }, + }, + "api service revision propagated when set": { + input: &APIMetric{ + EventID: "id-with-revision", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + APIServiceRevisionID: "revision-id-1", + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "id-with-revision", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + APIServiceRevision: &models.ResourceReference{ID: "revision-id-1"}, + }, + }, + "api owner demoted to unknown when cached APIService has empty team GUID": { + setupCache: func() { + agent.GetCacheManager().AddAPIService( + makeAPIServiceRI(testAPIEmptyGUID, &v1.Owner{Type: v1.TeamOwner, ID: ""}), + ) + }, + input: &APIMetric{ + EventID: "evt-api-demotion", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + API: models.APIDetails{ID: testAPIEmptyGUID, Name: "api-svc"}, + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "evt-api-demotion", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + API: &models.APIResourceReference{ + ResourceReference: models.ResourceReference{ID: testAPIEmptyGUID}, + Name: "api-svc", + Owner: &models.Owner{Type: "unknown"}, + }, + }, + }, + "app owner demoted to unknown when cached ManagedApp has empty team GUID": { + setupCache: func() { + agent.GetCacheManager().AddManagedApplication( + makeAppRI(testAppEmptyGUID, &v1.Owner{Type: v1.TeamOwner, ID: ""}), + ) + }, + input: &APIMetric{ + EventID: "evt-app-demotion", + Count: 1, + StatusCode: "200", + Observation: models.ObservationDetails{Start: 1, End: 2}, + App: models.AppDetails{ID: testAppEmptyGUID}, + }, + expectedOutput: ¢ralMetric{ + Version: "3", + Environment: &EnvironmentInfo{RuntimeType: runtimeTypeUnmanaged}, + EventID: "evt-app-demotion", + Observation: &models.ObservationDetails{Start: 1, End: 2}, + Reporter: &Reporter{ + AgentVersion: cmd.BuildVersion, + AgentType: cmd.BuildAgentName, + AgentSDKVersion: cmd.SDKBuildVersion, + AgentName: agent.GetCentralConfig().GetAgentName(), + ObservationDelta: 1, + }, + Units: &Units{ + Transactions: &Transactions{ + UnitCount: UnitCount{Count: 1}, + Duration: 0, + Response: &ResponseMetrics{}, + Status: "Success", + }, + }, + App: &models.ApplicationResourceReference{ + ResourceReference: models.ResourceReference{ID: testAppEmptyGUID}, + Owner: &models.Owner{Type: "unknown"}, + }, + }, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { if tc.skip { return } + agent.InitializeForTest(nil, agent.TestWithCentralConfig(testCfg)) + if tc.setupCache != nil { + tc.setupCache() + } output := centralMetricFromAPIMetric(tc.input) assert.Equal(t, tc.expectedOutput, output) }) } } + +func makeAppRI(name string, owner *v1.Owner) *v1.ResourceInstance { + app := management.NewManagedApplication(name, "env1") + app.Marketplace = management.ManagedApplicationMarketplace{ + Name: "mp1", + Resource: management.ManagedApplicationMarketplaceResource{Owner: owner}, + } + ri, _ := app.AsInstance() + return ri +} + +func newUtilCacheConfig() *config.CentralConfiguration { return &config.CentralConfiguration{} } + +func TestIsKnownID(t *testing.T) { + cases := map[string]struct { + id string + want bool + }{ + "empty string is not known": {id: "", want: false}, + "unknown literal is not known": {id: "unknown", want: false}, + "valid id is known": {id: "abc-123", want: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, isKnownID(tc.id)) + }) + } +} + +func TestCentralConfigFields(t *testing.T) { + cases := map[string]struct { + axwayManaged bool + agentName string + wantRuntime string + wantAgentName string + }{ + "non-managed config returns unmanaged": { + axwayManaged: false, + agentName: "agent-connected", + wantRuntime: runtimeTypeUnmanaged, + wantAgentName: "agent-connected", + }, + "axway-managed config returns managed": { + axwayManaged: true, + agentName: "agent-managed", + wantRuntime: runtimeTypeManaged, + wantAgentName: "agent-managed", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + cfg := &config.CentralConfiguration{AgentName: tc.agentName} + cfg.SetAxwayManaged(tc.axwayManaged) + agent.InitializeForTest(nil, agent.TestWithCentralConfig(cfg)) + + _, agentName, runtimeType := centralConfigFields() + assert.Equal(t, tc.wantRuntime, runtimeType) + assert.Equal(t, tc.wantAgentName, agentName) + }) + } +} + +func TestResolveAppOwnerFromCache(t *testing.T) { + cases := map[string]struct { + appRI *v1.ResourceInstance + appID string + wantType string + wantGUID string + }{ + "app not found in cache returns unknown": { + appRI: nil, + appID: "missing-app", + wantType: "unknown", + }, + "app found with team owner returns team block": { + appRI: makeAppRI("app-team", &v1.Owner{Type: v1.TeamOwner, ID: testTeamGUID1}), + appID: "app-team", + wantType: "team", + wantGUID: testTeamGUID1, + }, + "app with nil owner returns none": { + appRI: makeAppRI("app-no-owner", nil), + appID: "app-no-owner", + wantType: "none", + }, + "app with empty team GUID returns unknown": { + appRI: makeAppRI(testAppEmptyGUID, &v1.Owner{Type: v1.TeamOwner, ID: ""}), + appID: testAppEmptyGUID, + wantType: "unknown", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + agent.InitializeForTest(nil, agent.TestWithCentralConfig(newUtilCacheConfig())) + if tc.appRI != nil { + agent.GetCacheManager().AddManagedApplication(tc.appRI) + } + + owner := resolveAppOwnerFromCache(tc.appID) + require.NotNil(t, owner) + assert.Equal(t, tc.wantType, owner.Type) + if tc.wantGUID != "" { + assert.Equal(t, tc.wantGUID, owner.TeamGUID) + } + }) + } +} diff --git a/pkg/transaction/models/definitions.go b/pkg/transaction/models/definitions.go index 9f2d2da44..c63621881 100644 --- a/pkg/transaction/models/definitions.go +++ b/pkg/transaction/models/definitions.go @@ -25,17 +25,19 @@ type APIResourceReference struct { ResourceReference Name string `json:"name,omitempty"` APIServiceID string `json:"apiServiceId,omitempty"` - APIOwnerID string `json:"apiTeamId,omitempty"` + Owner *Owner `json:"owner,omitempty"` } type ApplicationResourceReference struct { ResourceReference ConsumerOrgID string `json:"consumerOrgId,omitempty"` + Owner *Owner `json:"owner,omitempty"` } type ProductResourceReference struct { ResourceReference VersionID string `json:"versionId,omitempty"` + Owner *Owner `json:"owner,omitempty"` } func (a ProductResourceReference) GetLogFields(fields logrus.Fields, idFieldName string) logrus.Fields { @@ -92,6 +94,7 @@ type Product struct { Name string `json:"name,omitempty"` VersionName string `json:"versionName,omitempty"` VersionID string `json:"versionId,omitempty"` + Owner *Owner `json:"owner,omitempty"` } func (a Product) GetLogFields(fields logrus.Fields) logrus.Fields { @@ -133,6 +136,7 @@ type APIDetails struct { Revision int `json:"revision,omitempty"` TeamID string `json:"teamId,omitempty"` APIServiceInstance string `json:"apiServiceInstance,omitempty"` + APIServiceID string `json:"apiServiceId,omitempty"` Stage string `json:"-"` Version string `json:"-"` } @@ -175,3 +179,11 @@ type ObservationDetails struct { Start int64 `json:"start,omitempty"` End int64 `json:"end,omitempty"` } + +// Owner represents the owner of an API or application resource in insights events. +// Type is one of "team", "user", "none", or "unknown". +type Owner struct { + Type string `json:"type"` + TeamGUID string `json:"team_guid,omitempty"` + UserGUID string `json:"user_guid,omitempty"` +} diff --git a/pkg/transaction/util/ownerresolver.go b/pkg/transaction/util/ownerresolver.go new file mode 100644 index 000000000..c85257543 --- /dev/null +++ b/pkg/transaction/util/ownerresolver.go @@ -0,0 +1,99 @@ +package util + +import ( + "strings" + + "github.com/Axway/agent-sdk/pkg/agent/cache" + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/transaction/models" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +var logger = log.NewFieldLogger(). + WithPackage("sdk.transaction.util"). + WithComponent("ownerresolver") + +func ResolveAPIOwner(apiExternalID string, cacheManager cache.Manager) *models.Owner { + if cacheManager == nil { + return &models.Owner{Type: "unknown"} + } + + apiID := strings.TrimPrefix(apiExternalID, SummaryEventProxyIDPrefix) + ri := cacheManager.GetAPIServiceWithAPIID(apiID) + if ri == nil { + logger.WithField("apiID", apiID).Trace("api service not found in cache, owner is unknown") + return &models.Owner{Type: "unknown"} + } + + svc := &management.APIService{} + if err := svc.FromInstance(ri); err != nil { + return &models.Owner{Type: "unknown"} + } + + if svc.Owner == nil { + logger.WithField("apiID", apiID).Trace("api service has no owner") + return &models.Owner{Type: "none"} + } + + if svc.Owner.Type == v1.TeamOwner { + if svc.Owner.ID == "" { + return &models.Owner{Type: "unknown"} + } + if svc.Owner.User != nil && svc.Owner.User.ID != "" { + logger.WithField("apiID", apiID).WithField("userGUID", svc.Owner.User.ID).Trace("resolved api owner as user (x-private team)") + return &models.Owner{Type: "user", TeamGUID: svc.Owner.ID, UserGUID: svc.Owner.User.ID} + } + logger.WithField("apiID", apiID).WithField("teamGUID", svc.Owner.ID).Trace("resolved api owner") + return &models.Owner{Type: "team", TeamGUID: svc.Owner.ID} + } + + return &models.Owner{Type: "unknown"} +} + +func ResolveProductOwner(ref v1.EmbeddedReference) *models.Owner { + if ref.Owner == nil { + return &models.Owner{Type: "none"} + } + if ref.Owner.Type == v1.TeamOwner { + if ref.Owner.ID == "" { + return &models.Owner{Type: "unknown"} + } + if ref.Owner.User != nil && ref.Owner.User.ID != "" { + return &models.Owner{Type: "user", TeamGUID: ref.Owner.ID, UserGUID: ref.Owner.User.ID} + } + return &models.Owner{Type: "team", TeamGUID: ref.Owner.ID} + } + return &models.Owner{Type: "unknown"} +} + +func ResolveAppOwnerFromManagedApp(manApp *v1.ResourceInstance) *models.Owner { + if manApp == nil { + return &models.Owner{Type: "unknown"} + } + + app := &management.ManagedApplication{} + if err := app.FromInstance(manApp); err != nil { + return &models.Owner{Type: "unknown"} + } + + owner := app.Marketplace.Resource.Owner + if owner == nil { + logger.WithField("appName", manApp.Name).Trace("managed application has no owner") + return &models.Owner{Type: "none"} + } + + if owner.Type == v1.TeamOwner { + if owner.ID == "" { + return &models.Owner{Type: "unknown"} + } + if owner.User != nil && owner.User.ID != "" { + logger.WithField("appName", manApp.Name).WithField("userGUID", owner.User.ID).Trace("resolved app owner as user (x-private team)") + return &models.Owner{Type: "user", TeamGUID: owner.ID, UserGUID: owner.User.ID} + } + logger.WithField("appName", manApp.Name).WithField("teamGUID", owner.ID).Trace("resolved app owner from managed application") + return &models.Owner{Type: "team", TeamGUID: owner.ID} + } + + return &models.Owner{Type: "unknown"} +} diff --git a/pkg/transaction/util/ownerresolver_test.go b/pkg/transaction/util/ownerresolver_test.go new file mode 100644 index 000000000..e8b52aa66 --- /dev/null +++ b/pkg/transaction/util/ownerresolver_test.go @@ -0,0 +1,166 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" + management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1alpha1" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/transaction/models" +) + +const ( + testOwnerTeamGUID1 = "team-guid-1" + testOwnerTeamGUID2 = "team-guid-2" + testOwnerUserGUID = "user-guid-1" +) + +func apiServiceRI(apiID string, owner *v1.Owner) *v1.ResourceInstance { + svc := management.NewAPIService("svc-"+apiID, "env1") + svc.SubResources = map[string]interface{}{ + "x-agent-details": map[string]interface{}{ + "externalAPIID": apiID, + }, + } + svc.Owner = owner + ri, _ := svc.AsInstance() + return ri +} + +func managedAppRI(name string, owner *v1.Owner) *v1.ResourceInstance { + app := management.NewManagedApplication(name, "env1") + app.Marketplace = management.ManagedApplicationMarketplace{ + Name: "mp1", + Resource: management.ManagedApplicationMarketplaceResource{ + Owner: owner, + }, + } + ri, _ := app.AsInstance() + return ri +} + +func newCacheWithAPIService(apiID string, owner *v1.Owner) agentcache.Manager { + m := agentcache.NewAgentCacheManager(&config.CentralConfiguration{}, false) + m.AddAPIService(apiServiceRI(apiID, owner)) + return m +} + +func TestResolveAPIOwner(t *testing.T) { + tests := map[string]struct { + apiID string + cache agentcache.Manager + expected *models.Owner + }{ + "nil cache manager returns unknown": { + apiID: "api-1", + cache: nil, + expected: &models.Owner{Type: "unknown"}, + }, + "cache miss returns unknown": { + apiID: "not-in-cache", + cache: newCacheWithAPIService("api-1", &v1.Owner{Type: v1.TeamOwner, ID: "team-1"}), + expected: &models.Owner{Type: "unknown"}, + }, + "api service with nil owner": { + apiID: "api-2", + cache: newCacheWithAPIService("api-2", nil), + expected: &models.Owner{Type: "none"}, + }, + "api service team owner with GUID": { + apiID: "api-3", + cache: newCacheWithAPIService("api-3", &v1.Owner{Type: v1.TeamOwner, ID: "team-guid-3"}), + expected: &models.Owner{Type: "team", TeamGUID: "team-guid-3"}, + }, + "api service team owner with empty GUID": { + apiID: "api-4", + cache: newCacheWithAPIService("api-4", &v1.Owner{Type: v1.TeamOwner, ID: ""}), + expected: &models.Owner{Type: "unknown"}, + }, + "prefix stripped before lookup": { + apiID: SummaryEventProxyIDPrefix + "api-5", + cache: newCacheWithAPIService("api-5", &v1.Owner{Type: v1.TeamOwner, ID: "team-5"}), + expected: &models.Owner{Type: "team", TeamGUID: "team-5"}, + }, + "api service x-private owner": { + apiID: "api-6", + cache: newCacheWithAPIService("api-6", &v1.Owner{Type: v1.TeamOwner, ID: "team-guid-6", User: &v1.OwnerUser{ID: testOwnerUserGUID}}), + expected: &models.Owner{Type: "user", TeamGUID: "team-guid-6", UserGUID: testOwnerUserGUID}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, ResolveAPIOwner(tc.apiID, tc.cache)) + }) + } +} + +func TestResolveProductOwner(t *testing.T) { + tests := map[string]struct { + ref v1.EmbeddedReference + expected *models.Owner + }{ + "empty embedded reference": { + ref: v1.EmbeddedReference{}, + expected: &models.Owner{Type: "none"}, + }, + "product ref team owner with GUID": { + ref: v1.EmbeddedReference{Owner: &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID1}}, + expected: &models.Owner{Type: "team", TeamGUID: testOwnerTeamGUID1}, + }, + "product ref team owner with empty GUID": { + ref: v1.EmbeddedReference{Owner: &v1.Owner{Type: v1.TeamOwner, ID: ""}}, + expected: &models.Owner{Type: "unknown"}, + }, + "reference with name but no owner returns none": { + ref: v1.EmbeddedReference{Kind: "PublishedProduct", Name: "product-1"}, + expected: &models.Owner{Type: "none"}, + }, + "product ref x-private owner": { + ref: v1.EmbeddedReference{Owner: &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID1, User: &v1.OwnerUser{ID: testOwnerUserGUID}}}, + expected: &models.Owner{Type: "user", TeamGUID: testOwnerTeamGUID1, UserGUID: testOwnerUserGUID}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, ResolveProductOwner(tc.ref)) + }) + } +} + +func TestResolveAppOwnerFromManagedApp(t *testing.T) { + tests := map[string]struct { + manApp *v1.ResourceInstance + expected *models.Owner + }{ + "nil resource instance returns unknown": { + manApp: nil, + expected: &models.Owner{Type: "unknown"}, + }, + "managed app with nil owner": { + manApp: managedAppRI("app-1", nil), + expected: &models.Owner{Type: "none"}, + }, + "managed app team owner with GUID": { + manApp: managedAppRI("app-2", &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID2}), + expected: &models.Owner{Type: "team", TeamGUID: testOwnerTeamGUID2}, + }, + "managed app team owner with empty GUID": { + manApp: managedAppRI("app-3", &v1.Owner{Type: v1.TeamOwner, ID: ""}), + expected: &models.Owner{Type: "unknown"}, + }, + "managed app x-private owner": { + manApp: managedAppRI("app-4", &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID2, User: &v1.OwnerUser{ID: testOwnerUserGUID}}), + expected: &models.Owner{Type: "user", TeamGUID: testOwnerTeamGUID2, UserGUID: testOwnerUserGUID}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, ResolveAppOwnerFromManagedApp(tc.manApp)) + }) + } +} diff --git a/pkg/transaction/v2event.go b/pkg/transaction/v2event.go new file mode 100644 index 000000000..b7f154bb2 --- /dev/null +++ b/pkg/transaction/v2event.go @@ -0,0 +1,539 @@ +package transaction + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/transaction/metric" + "github.com/Axway/agent-sdk/pkg/transaction/models" + transutil "github.com/Axway/agent-sdk/pkg/transaction/util" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +const ( + insightsEventVersion = "4" + legDataVersion = "2" + summaryDataVersion = "2" +) + +// ReporterInfo holds the agent build metadata for the reporter block. +type ReporterInfo struct { + AgentVersion string + AgentType string + AgentSDKVersion string + AgentName string + // ObservationDelta is only meaningful for metric events. Leave 0 for transaction events. + ObservationDelta int64 +} + +// insightsAPIDetail is the api sub-object shared by leg and summary v2 data. +type insightsAPIDetail struct { + ID string `json:"id"` + APIServiceID string `json:"apiServiceId,omitempty"` + Name string `json:"name,omitempty"` + Owner *models.Owner `json:"owner,omitempty"` +} + +// insightsConsumerAppDetail is the application sub-object within consumerDetails. +type insightsConsumerAppDetail struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ConsumerOrgID string `json:"consumerOrgId,omitempty"` + Owner *models.Owner `json:"owner,omitempty"` +} + +// insightsPublishedProduct is the publishedProduct sub-object within consumerDetails. +type insightsPublishedProduct struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// insightsSubscription is the subscription sub-object within consumerDetails. +type insightsSubscription struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// insightsConsumerDetails is the consumerDetails sub-object for leg and summary v2. +type insightsConsumerDetails struct { + ConsumerOrgID string `json:"consumerOrgId,omitempty"` + Marketplace *insightsMarketplace `json:"marketplace,omitempty"` + Application *insightsConsumerAppDetail `json:"application,omitempty"` + PublishedProduct *insightsPublishedProduct `json:"publishedProduct,omitempty"` + Subscription *insightsSubscription `json:"subscription,omitempty"` +} + +type insightsMarketplace struct { + GUID string `json:"guid,omitempty"` +} + +// insightsSummaryProduct is the product sub-object for summary v2. +type insightsSummaryProduct struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + VersionID string `json:"versionId,omitempty"` + VersionName string `json:"versionName,omitempty"` + Owner *models.Owner `json:"owner,omitempty"` +} + +// insightsReporter is the reporter sub-object. +type insightsReporter struct { + Version string `json:"version,omitempty"` + Type string `json:"type,omitempty"` + AgentSDKVersion string `json:"agentSDKVersion,omitempty"` + AgentName string `json:"agentName,omitempty"` + ObservationDelta int64 `json:"observationDelta,omitempty"` +} + +// legProtocol is the protocol sub-object for TransactionLegData. +type legProtocol struct { + Type string `json:"type,omitempty"` + URI string `json:"uri,omitempty"` + Method string `json:"method,omitempty"` + Status int `json:"status"` +} + +// insightsProxy is the nested proxy sub-object for leg and summary v2 data. +type insightsProxy struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// TransactionLegData is the data payload for api.transaction.event (version "2"). +type TransactionLegData struct { + Version string `json:"version"` + APICDeployment string `json:"apicDeployment,omitempty"` + TransactionID string `json:"transactionId,omitempty"` + ID string `json:"id,omitempty"` + LegID int `json:"legId"` + ParentID string `json:"parentId,omitempty"` + Source string `json:"source,omitempty"` + Destination string `json:"destination,omitempty"` + Status string `json:"status,omitempty"` + Duration int `json:"duration"` + Direction string `json:"direction,omitempty"` + Protocol *legProtocol `json:"protocol,omitempty"` + API *insightsAPIDetail `json:"api,omitempty"` + Reporter *insightsReporter `json:"reporter,omitempty"` + Proxy *insightsProxy `json:"proxy,omitempty"` +} + +// GetStartTime implements metric.V4Data. +func (d *TransactionLegData) GetStartTime() time.Time { return time.Time{} } + +// GetType implements metric.V4Data. +func (d *TransactionLegData) GetType() string { return TypeTransactionEvent } + +// GetEventID implements metric.V4Data. +func (d *TransactionLegData) GetEventID() string { return d.TransactionID } + +// GetLogFields implements metric.V4Data. +func (d *TransactionLegData) GetLogFields() logrus.Fields { + f := logrus.Fields{"transactionId": d.TransactionID, "legId": d.LegID} + if d.API != nil { + f["apiId"] = d.API.ID + } + return f +} + +// insightsEntryPoint is the entryPoint sub-object for summary v2. +type insightsEntryPoint struct { + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` +} + +// insightsResourceRef is a minimal {id} sub-object. +type insightsResourceRef struct { + ID string `json:"id,omitempty"` +} + +// TransactionSummaryData is the data payload for api.transaction.summary (version "2"). +type TransactionSummaryData struct { + Version string `json:"version"` + APICDeployment string `json:"apicDeployment,omitempty"` + Status string `json:"status,omitempty"` + StatusDetail string `json:"statusDetail,omitempty"` + Duration int `json:"duration"` + API *insightsAPIDetail `json:"api,omitempty"` + EntryPoint *insightsEntryPoint `json:"entryPoint,omitempty"` + AssetResource *insightsResourceRef `json:"assetResource,omitempty"` + APIServiceRevision *insightsResourceRef `json:"apiServiceRevision,omitempty"` + Product *insightsSummaryProduct `json:"product,omitempty"` + ProductPlan *insightsResourceRef `json:"productPlan,omitempty"` + Quota *insightsResourceRef `json:"quota,omitempty"` + Reporter *insightsReporter `json:"reporter,omitempty"` + ConsumerDetails *insightsConsumerDetails `json:"consumerDetails,omitempty"` + Proxy *insightsProxy `json:"proxy,omitempty"` +} + +// GetStartTime implements metric.V4Data. +func (d *TransactionSummaryData) GetStartTime() time.Time { return time.Time{} } + +// GetType implements metric.V4Data. +func (d *TransactionSummaryData) GetType() string { return TypeTransactionSummary } + +// GetEventID implements metric.V4Data. +func (d *TransactionSummaryData) GetEventID() string { return "" } + +// GetLogFields implements metric.V4Data. +func (d *TransactionSummaryData) GetLogFields() logrus.Fields { + f := logrus.Fields{"status": d.Status} + if d.API != nil { + f["apiId"] = d.API.ID + } + return f +} + +// InsightsEvent is the top-level envelope for transaction leg and summary events. +type InsightsEvent struct { + ID string `json:"id"` + Org string `json:"org"` + Event string `json:"event"` + Version string `json:"version"` + Timestamp int64 `json:"timestamp"` + Distribution *metric.V4EventDistribution `json:"distribution"` + Data metric.V4Data `json:"data"` + Session *metric.V4Session `json:"session,omitempty"` +} + +// eventTypeMap maps LogEvent.Type to the insights event name. +var eventTypeMap = map[string]string{ + TypeTransactionEvent: "api.transaction.event", + TypeTransactionSummary: "api.transaction.summary", +} + +// BuildTransactionV2Data constructs an InsightsEvent from a LogEvent for the ground agent path. +// cacheManager may be nil in tests. Owner resolution will return "unknown" in that case. +func BuildTransactionV2Data( + logger log.FieldLogger, + logEvent LogEvent, + orgID string, + environmentID string, + summaryProxy *Proxy, + cacheManager cache.Manager, + reporter ReporterInfo, +) (*InsightsEvent, error) { + logger. + WithField("transactionID", logEvent.TransactionID). + WithField("eventType", logEvent.Type). + Debug("building insights v2 event") + + eventName, ok := eventTypeMap[logEvent.Type] + if !ok { + return nil, fmt.Errorf("unknown logEvent type %q for InsightsEvent construction", logEvent.Type) + } + + env := &metric.V4EventDistribution{Environment: environmentID} + ie := &InsightsEvent{ + ID: uuid.NewString(), + Org: orgID, + Event: eventName, + Version: insightsEventVersion, + Timestamp: logEvent.Stamp, + Distribution: env, + } + + switch logEvent.Type { + case TypeTransactionEvent: + data, err := buildLegV2Data(logEvent, summaryProxy, cacheManager, reporter) + if err != nil { + return nil, err + } + ie.Data = data + ie.Session = &metric.V4Session{ID: logEvent.TransactionID} + case TypeTransactionSummary: + data, err := buildSummaryV2Data(logger, logEvent, cacheManager, reporter) + if err != nil { + return nil, err + } + ie.Data = data + ie.Session = &metric.V4Session{ID: logEvent.TransactionID} + } + + logger. + WithField("transactionID", logEvent.TransactionID). + WithField("insightsEventID", ie.ID). + WithField("eventName", ie.Event). + Debug("insights v2 event built successfully") + + return ie, nil +} + +func buildLegV2Data(logEvent LogEvent, summaryProxy *Proxy, cacheManager cache.Manager, reporter ReporterInfo) (*TransactionLegData, error) { + txEvent := logEvent.TransactionEvent + if txEvent == nil { + return nil, fmt.Errorf("TransactionEvent is nil for logEvent type %q", logEvent.Type) + } + + legID := parseLegID(txEvent.ID) + + var proto *legProtocol + if httpProto, ok := txEvent.Protocol.(*Protocol); ok && httpProto != nil { + proto = &legProtocol{ + Type: httpProto.Type, + URI: httpProto.URI, + Method: httpProto.Method, + Status: httpProto.Status, + } + } + + apiID := txEvent.ProxyID + proxyName := txEvent.ProxyName + + if apiID == "" && summaryProxy != nil { + apiID = summaryProxy.ID + if proxyName == "" { + proxyName = summaryProxy.Name + } + } + if apiID == "" && txEvent.Source != "" { + apiID = transutil.ResolveIDWithPrefix(txEvent.Source, "") + } + + apiDetail := resolveAPIDetailFromCache(apiID, cacheManager) + if proxyName == "" && apiDetail != nil { + proxyName = apiDetail.Name + } + var legProxy *insightsProxy + if apiID != "" || proxyName != "" { + legProxy = &insightsProxy{ID: apiID, Name: proxyName} + } + + data := &TransactionLegData{ + Version: legDataVersion, + APICDeployment: logEvent.APICDeployment, + TransactionID: logEvent.TransactionID, + ID: fmt.Sprintf("leg%d", legID), // legID already normalized via parseLegID + LegID: legID, + ParentID: formatLegID(txEvent.ParentID), + Source: txEvent.Source, + Destination: txEvent.Destination, + Status: txEvent.Status, + Duration: txEvent.Duration, + Direction: strings.ToLower(txEvent.Direction), + Protocol: proto, + API: apiDetail, + Proxy: legProxy, + Reporter: &insightsReporter{ + Version: reporter.AgentVersion, + Type: reporter.AgentType, + AgentSDKVersion: reporter.AgentSDKVersion, + AgentName: reporter.AgentName, + }, + } + + return data, nil +} + +func buildSummaryV2Data(logger log.FieldLogger, logEvent LogEvent, cacheManager cache.Manager, reporter ReporterInfo) (*TransactionSummaryData, error) { + summary := logEvent.TransactionSummary + if summary == nil { + return nil, fmt.Errorf("TransactionSummary is nil for logEvent type %q", logEvent.Type) + } + + apiID, apiName, apiServiceRevisionID := resolveSummaryAPIInfo(summary) + + apiServiceID := "" + if summary.API != nil { + apiServiceID = summary.API.APIServiceID + } + + data := &TransactionSummaryData{ + Version: summaryDataVersion, + APICDeployment: logEvent.APICDeployment, + Status: summary.Status, + StatusDetail: summary.StatusDetail, + Duration: summary.Duration, + API: buildSummaryAPIDetail(logger, apiID, apiName, apiServiceID, summary.OwnerInfo, cacheManager), + EntryPoint: buildEntryPoint(summary.EntryPoint), + AssetResource: buildAssetResourceRef(summary.AssetResource), + APIServiceRevision: buildAPIServiceRevisionRef(apiServiceRevisionID, summary.API), + Reporter: &insightsReporter{ + Version: reporter.AgentVersion, + Type: reporter.AgentType, + AgentSDKVersion: reporter.AgentSDKVersion, + AgentName: reporter.AgentName, + ObservationDelta: reporter.ObservationDelta, + }, + } + + if summary.Product != nil && summary.Product.ID != "" { + data.Product = &insightsSummaryProduct{ + ID: summary.Product.ID, + Name: summary.Product.Name, + VersionID: summary.Product.VersionID, + VersionName: summary.Product.VersionName, + Owner: summary.Product.Owner, + } + } + if summary.ProductPlan != nil && summary.ProductPlan.ID != "" { + data.ProductPlan = &insightsResourceRef{ID: summary.ProductPlan.ID} + } + if summary.Quota != nil && summary.Quota.ID != "" { + data.Quota = &insightsResourceRef{ID: summary.Quota.ID} + } + + if summary.ConsumerDetails != nil { + data.ConsumerDetails = buildConsumerDetails(summary.ConsumerDetails, summary.AppOwnerInfo) + } + + if summary.Proxy != nil && (summary.Proxy.ID != "" || summary.Proxy.Name != "") { + data.Proxy = &insightsProxy{ID: summary.Proxy.ID, Name: summary.Proxy.Name} + } else if summary.API != nil && (summary.API.ID != "" || summary.API.Name != "") { + // Proxy doesn't survive JSON round-trips (json:"-"). Fall back to API details + // which carry the same proxy ID/name set by the controller enrichment path. + proxyID := strings.TrimPrefix(summary.API.ID, transutil.SummaryEventProxyIDPrefix) + data.Proxy = &insightsProxy{ID: proxyID, Name: summary.API.Name} + } + + return data, nil +} + +func resolveSummaryAPIInfo(summary *Summary) (apiID, apiName, apiServiceRevisionID string) { + if summary.Proxy != nil { + apiID = transutil.ResolveIDWithPrefix(summary.Proxy.ID, summary.Proxy.Name) + apiName = summary.Proxy.Name + // Still pick up the revision ID from summary.API when available. + if summary.API != nil { + apiServiceRevisionID = summary.API.APIServiceInstance + } + return + } + if summary.API != nil { + apiID = transutil.ResolveIDWithPrefix(summary.API.ID, summary.API.Name) + apiName = summary.API.Name + apiServiceRevisionID = summary.API.APIServiceInstance + } + return +} + +func buildSummaryAPIDetail(logger log.FieldLogger, apiID, apiName, apiServiceID string, ownerInfo *models.Owner, cacheManager cache.Manager) *insightsAPIDetail { + var apiOwner *models.Owner + if ownerInfo != nil { + apiOwner = ownerInfo + logger.WithField("apiID", apiID).Trace("using pre-populated api owner from summary") + } else { + apiOwner = transutil.ResolveAPIOwner(apiID, cacheManager) + logger.WithField("apiID", apiID).WithField("ownerType", apiOwner.Type).Trace("resolved api owner from cache") + } + + detail := &insightsAPIDetail{ID: apiID, Name: apiName, Owner: apiOwner, APIServiceID: apiServiceID} + if apiServiceID == "" && cacheManager != nil { + stripped := strings.TrimPrefix(apiID, transutil.SummaryEventProxyIDPrefix) + if svc := cacheManager.GetAPIServiceWithAPIID(stripped); svc != nil { + detail.APIServiceID = svc.Metadata.ID + } + } + return detail +} + +func buildEntryPoint(ep *EntryPoint) *insightsEntryPoint { + if ep == nil { + return nil + } + return &insightsEntryPoint{Method: ep.Method, Path: ep.Path, Host: ep.Host} +} + +func buildAssetResourceRef(ar *models.AssetResource) *insightsResourceRef { + if ar == nil || ar.ID == "" || ar.ID == unknown { + return nil + } + return &insightsResourceRef{ID: ar.ID} +} + +func buildAPIServiceRevisionRef(revisionID string, api *models.APIDetails) *insightsResourceRef { + if revisionID == "" && api != nil { + revisionID = api.APIServiceInstance + } + if revisionID == "" { + return nil + } + return &insightsResourceRef{ID: revisionID} +} + +func buildConsumerDetails(cd *models.ConsumerDetails, appOwner *models.Owner) *insightsConsumerDetails { + out := &insightsConsumerDetails{} + + if cd.Marketplace != nil { + out.ConsumerOrgID = cd.Marketplace.ConsumerOrgID + out.Marketplace = &insightsMarketplace{GUID: cd.Marketplace.GUID} + } + + appDetail := &insightsConsumerAppDetail{} + if cd.Application != nil { + appDetail.ID = cd.Application.ID + appDetail.Name = cd.Application.Name + appDetail.ConsumerOrgID = cd.Application.ConsumerOrgID + } + if appOwner != nil { + appDetail.Owner = appOwner + } + out.Application = appDetail + + if cd.PublishedProduct != nil && cd.PublishedProduct.ID != "" { + out.PublishedProduct = &insightsPublishedProduct{ + ID: cd.PublishedProduct.ID, + Name: cd.PublishedProduct.Name, + } + } + if cd.Subscription != nil && cd.Subscription.ID != "" { + out.Subscription = &insightsSubscription{ + ID: cd.Subscription.ID, + Name: cd.Subscription.Name, + } + } + + return out +} + +// SetLegProxy sets the proxy block on a TransactionLegData after it has been built. +// Used by the agents-controller to populate proxy from enriched API details post-build, +// since insightsProxy is unexported and cannot be constructed externally. +func SetLegProxy(leg *TransactionLegData, proxyID, proxyName string) { + if leg == nil || (proxyID == "" && proxyName == "") { + return + } + leg.Proxy = &insightsProxy{ID: proxyID, Name: proxyName} +} + +// parseLegID accepts "N" or "legN" and returns N; returns 0 on any other input. +func parseLegID(s string) int { + n, err := strconv.Atoi(strings.TrimPrefix(s, "leg")) + if err != nil || n < 0 { + return 0 + } + return n +} + +// formatLegID normalizes s to "legN" form. Already-prefixed values ("leg0") pass through unchanged. +// Plain integers ("0") are prefixed. Anything else is returned as-is for backwards compatibility. +func formatLegID(s string) string { + if strings.HasPrefix(s, "leg") { + return s + } + if _, err := strconv.Atoi(s); err == nil { + return "leg" + s + } + return s +} + +// resolveAPIDetailFromCache builds the api sub-object for leg events. +// apiServiceId is intentionally omitted — it is summary-only. +func resolveAPIDetailFromCache(apiID string, cacheManager cache.Manager) *insightsAPIDetail { + detail := &insightsAPIDetail{ + ID: apiID, + Owner: &models.Owner{Type: "unknown"}, + } + if cacheManager == nil || apiID == "" { + return detail + } + detail.Owner = transutil.ResolveAPIOwner(apiID, cacheManager) + return detail +} diff --git a/pkg/transaction/v2event_test.go b/pkg/transaction/v2event_test.go new file mode 100644 index 000000000..dfc60ac2d --- /dev/null +++ b/pkg/transaction/v2event_test.go @@ -0,0 +1,1279 @@ +package transaction + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Axway/agent-sdk/pkg/traceability/redaction" + "github.com/Axway/agent-sdk/pkg/transaction/metric" + "github.com/Axway/agent-sdk/pkg/transaction/models" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +// compile-time assertions. Both data types must satisfy the V4Data interface. +var _ metric.V4Data = (*TransactionLegData)(nil) +var _ metric.V4Data = (*TransactionSummaryData)(nil) + +const ( + testOrgID = "org-1" + testEnvID = "env-1" + testOrgABC = "org-abc" + testEnvABC = "env-abc" + testOrgXYZ = "org-xyz" + testEnvXYZ = "env-xyz" + testTxnLeg1 = "txn-leg-1" + testTxnLeg2 = "txn-leg-2" + testTxnLeg3 = "txn-leg-3" + testTxnSum1 = "txn-sum-1" + testTxnSum2 = "txn-sum-2" + testTxnSum3 = "txn-sum-3" + testTxnSum4 = "txn-sum-4" + testTxnSumJSON = "txn-sum-json" + testTxnErrLeg = "txn-err-1" + testTxnErrSummary = "txn-err-2" + testTxnUnknown = "txn-1" + testEventLegName = "api.transaction.event" + testEventSumName = "api.transaction.summary" + testTxnOwner1 = "txn-owner-1" + testTxnOwner2 = "txn-owner-2" + testTxnOwner3 = "txn-owner-3" + testTxnOwner4 = "txn-owner-4" + testTxnOwner5 = "txn-owner-5" + testTxnNoFields1 = "txn-nofields-leg" + testTxnNoFields2 = "txn-nofields-sum" + testTeamGUID = "team-guid-123" + testTxnOutbound1 = "txn-outbound-1" + testTxnReporter = "txn-reporter-1" + testTxnAsset = "txn-asset-1" + testTxnRevision = "txn-revision-1" + testTxnExclLeg = "txn-excl-leg" + testTxnExclSum = "txn-excl-sum" + testTxnIfaceLeg = "txn-iface-leg" + testTxnProduct1 = "txn-product-1" + testTxnProduct2 = "txn-product-2" + testTxnProduct3 = "txn-product-3" + testTxnConsumer1 = "txn-consumer-1" + testTxnConsumer2 = "txn-consumer-2" + testConsumerOrgID = "consumer-org-1" + testTxnProxyRev = "txn-proxy-rev" + testTxnObsDelta = "txn-obs-delta" + testRevisionUUID1 = "revision-uuid-abc123" + testRevisionUUID2 = "revision-uuid-def456" + testRevisionUUID3 = "revision-uuid-ghi789" + testProxyName = "proxy-name" + testProxyID = "proxy-id" + testEntryPointHost = "host.example.com" + testAPIName = "my-api" +) + +func TestBuildTransactionV2Data(t *testing.T) { + reporter := ReporterInfo{ + AgentVersion: "1.0.0", + AgentType: "TestAgent", + AgentSDKVersion: "0.0.1", + AgentName: "test-agent", + } + + tests := map[string]struct { + logEvent LogEvent + orgID string + environmentID string + wantErr bool + check func(t *testing.T, ie *InsightsEvent) + }{ + "unknown event type returns error": { + logEvent: LogEvent{Type: "unknownType", TransactionID: testTxnUnknown}, + orgID: testOrgID, + environmentID: testEnvID, + wantErr: true, + }, + "nil TransactionEvent for leg type returns error": { + logEvent: LogEvent{Type: TypeTransactionEvent, TransactionID: testTxnErrLeg}, + orgID: testOrgID, + environmentID: testEnvID, + wantErr: true, + }, + "nil TransactionSummary for summary type returns error": { + logEvent: LogEvent{Type: TypeTransactionSummary, TransactionID: testTxnErrSummary}, + orgID: testOrgID, + environmentID: testEnvID, + wantErr: true, + }, + "transaction leg event has correct envelope": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnLeg1, + Stamp: time.Now().UnixMilli(), + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + orgID: testOrgABC, + environmentID: testEnvABC, + check: func(t *testing.T, ie *InsightsEvent) { + assert.Equal(t, insightsEventVersion, ie.Version) + assert.Equal(t, testEventLegName, ie.Event) + assert.Equal(t, testOrgABC, ie.Org) + assert.NotEmpty(t, ie.ID) + require.NotNil(t, ie.Distribution) + assert.Equal(t, testEnvABC, ie.Distribution.Environment) + require.NotNil(t, ie.Session) + assert.Equal(t, testTxnLeg1, ie.Session.ID) + + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, legDataVersion, data.Version) + assert.Equal(t, testTxnLeg1, data.TransactionID) + assert.Equal(t, "leg0", data.ID) + }, + }, + "transaction leg apic deployment and non-zero leg id": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnLeg2, + APICDeployment: "prod", + TransactionEvent: &Event{ID: "1", Status: "Pass"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, legDataVersion, data.Version) + assert.Equal(t, "prod", data.APICDeployment) + assert.Equal(t, 1, data.LegID) + assert.Equal(t, "leg1", data.ID) + }, + }, + "entry leg id is zero": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnLeg3, + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, 0, data.LegID) + assert.Equal(t, "leg0", data.ID) + }, + }, + "leg with protocol fields are nested under protocol object": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-proto-1", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Protocol: &Protocol{ + URI: "/v1/resource", + Method: "POST", + Status: 201, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + require.NotNil(t, data.Protocol) + assert.Equal(t, "/v1/resource", data.Protocol.URI) + assert.Equal(t, "POST", data.Protocol.Method) + assert.Equal(t, 201, data.Protocol.Status) + + b, err := json.Marshal(data) + require.NoError(t, err) + var top map[string]interface{} + require.NoError(t, json.Unmarshal(b, &top)) + assert.Contains(t, top, "protocol") + assert.NotContains(t, top, "uri") + assert.NotContains(t, top, "method") + assert.NotContains(t, top, "statusCode") + }, + }, + "leg without protocol has nil protocol field": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-no-proto", + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Nil(t, data.Protocol) + }, + }, + "outbound leg parentId source and destination are populated": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnOutbound1, + TransactionEvent: &Event{ + ID: "1", + ParentID: testTxnOutbound1, + Source: "client", + Destination: "backend-service", + Status: "Pass", + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, testTxnOutbound1, data.ParentID) + assert.Equal(t, "client", data.Source) + assert.Equal(t, "backend-service", data.Destination) + }, + }, + "entry leg has no parentId": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-entry-parent", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Empty(t, data.ParentID) + + b, err := json.Marshal(data) + require.NoError(t, err) + assert.NotContains(t, string(b), `"parentId"`) + }, + }, + "transaction summary event has correct envelope": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnSum1, + Stamp: time.Now().UnixMilli(), + TransactionSummary: &Summary{ + Status: "Success", + Duration: 150, + Proxy: &Proxy{ID: "proxy-1", Name: testAPIName}, + }, + }, + orgID: testOrgXYZ, + environmentID: testEnvXYZ, + check: func(t *testing.T, ie *InsightsEvent) { + assert.Equal(t, insightsEventVersion, ie.Version) + assert.Equal(t, testEventSumName, ie.Event) + assert.Equal(t, testOrgXYZ, ie.Org) + assert.NotEmpty(t, ie.ID) + require.NotNil(t, ie.Session) + assert.Equal(t, testTxnSum1, ie.Session.ID) + + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + assert.Equal(t, summaryDataVersion, data.Version) + assert.Equal(t, "Success", data.Status) + assert.Equal(t, 150, data.Duration) + }, + }, + "summary with proxy populates deprecated fields": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnSum2, + TransactionSummary: &Summary{ + Status: "Success", + Proxy: &Proxy{ID: "proxy-id-2", Name: "proxy-name-2"}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.Proxy) + assert.Equal(t, "proxy-id-2", data.Proxy.ID) + assert.Equal(t, "proxy-name-2", data.Proxy.Name) + }, + }, + "summary without proxy has no proxy block": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnSum3, + TransactionSummary: &Summary{Status: "Success"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + assert.Nil(t, data.Proxy) + }, + }, + "summary with entry point is populated": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnSum4, + TransactionSummary: &Summary{ + Status: "Success", + EntryPoint: &EntryPoint{ + Method: "GET", + Path: "/path", + Host: testEntryPointHost, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.EntryPoint) + assert.Equal(t, "GET", data.EntryPoint.Method) + assert.Equal(t, "/path", data.EntryPoint.Path) + assert.Equal(t, testEntryPointHost, data.EntryPoint.Host) + }, + }, + "summary event is JSON serializable with correct version fields": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnSumJSON, + TransactionSummary: &Summary{Status: "Success", Duration: 100}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + b, err := json.Marshal(ie) + assert.Nil(t, err) + assert.Contains(t, string(b), `"version":"4"`) + assert.Contains(t, string(b), `"api.transaction.summary"`) + }, + }, + // summary OwnerInfo pre-populated by agents-controller takes precedence over cache + "summary with pre-populated OwnerInfo uses it directly": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnOwner1, + TransactionSummary: &Summary{ + Status: "Success", + OwnerInfo: &models.Owner{ + Type: "team", + TeamGUID: testTeamGUID, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.API) + require.NotNil(t, data.API.Owner) + assert.Equal(t, "team", data.API.Owner.Type) + assert.Equal(t, testTeamGUID, data.API.Owner.TeamGUID) + }, + }, + // summary AppOwnerInfo propagates to consumerDetails.application.owner + "summary AppOwnerInfo propagates to consumerDetails application owner": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnOwner2, + TransactionSummary: &Summary{ + Status: "Success", + AppOwnerInfo: &models.Owner{ + Type: "team", + TeamGUID: testTeamGUID, + }, + ConsumerDetails: &models.ConsumerDetails{}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ConsumerDetails) + require.NotNil(t, data.ConsumerDetails.Application) + require.NotNil(t, data.ConsumerDetails.Application.Owner) + assert.Equal(t, "team", data.ConsumerDetails.Application.Owner.Type) + assert.Equal(t, testTeamGUID, data.ConsumerDetails.Application.Owner.TeamGUID) + }, + }, + // nil OwnerInfo falls through to "unknown" when cacheManager is nil + "summary nil OwnerInfo with nil cache produces unknown owner": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnOwner3, + TransactionSummary: &Summary{ + Status: "Success", + OwnerInfo: nil, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.API) + require.NotNil(t, data.API.Owner) + assert.Equal(t, "unknown", data.API.Owner.Type) + }, + }, + // nil AppOwnerInfo produces no owner on consumerDetails application + "summary nil AppOwnerInfo produces no application owner": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnOwner4, + TransactionSummary: &Summary{ + Status: "Success", + AppOwnerInfo: nil, + ConsumerDetails: &models.ConsumerDetails{}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ConsumerDetails) + require.NotNil(t, data.ConsumerDetails.Application) + assert.Nil(t, data.ConsumerDetails.Application.Owner) + }, + }, + // marketplace GUID and consumerOrgId propagate through consumerDetails + "summary marketplace details propagate to consumerDetails": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnOwner5, + TransactionSummary: &Summary{ + Status: "Success", + ConsumerDetails: &models.ConsumerDetails{ + Marketplace: &models.MarketplaceReference{ + GUID: "mp-guid-1", + ConsumerOrgID: testConsumerOrgID, + }, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ConsumerDetails) + assert.Equal(t, testConsumerOrgID, data.ConsumerDetails.ConsumerOrgID) + require.NotNil(t, data.ConsumerDetails.Marketplace) + assert.Equal(t, "mp-guid-1", data.ConsumerDetails.Marketplace.GUID) + }, + }, + "summary with product populates product block": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnProduct1, + TransactionSummary: &Summary{ + Status: "Success", + Product: &models.Product{ + ID: "prod-id-1", + Name: "my-product", + VersionID: "ver-id-1", + VersionName: "1.0.0", + Owner: &models.Owner{Type: "team", TeamGUID: testTeamGUID}, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.Product) + assert.Equal(t, "prod-id-1", data.Product.ID) + assert.Equal(t, "my-product", data.Product.Name) + assert.Equal(t, "ver-id-1", data.Product.VersionID) + assert.Equal(t, "1.0.0", data.Product.VersionName) + require.NotNil(t, data.Product.Owner) + assert.Equal(t, testTeamGUID, data.Product.Owner.TeamGUID) + }, + }, + "summary with productPlan and quota populates those fields": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnProduct2, + TransactionSummary: &Summary{ + Status: "Success", + ProductPlan: &models.ProductPlan{ID: "plan-id-1"}, + Quota: &models.Quota{ID: "quota-id-1"}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ProductPlan) + assert.Equal(t, "plan-id-1", data.ProductPlan.ID) + require.NotNil(t, data.Quota) + assert.Equal(t, "quota-id-1", data.Quota.ID) + }, + }, + "summary with empty product ID omits product block": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnProduct3, + TransactionSummary: &Summary{ + Status: "Success", + Product: &models.Product{ID: ""}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + assert.Nil(t, data.Product) + }, + }, + "summary consumerDetails application fields are populated": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnConsumer1, + TransactionSummary: &Summary{ + Status: "Success", + ConsumerDetails: &models.ConsumerDetails{ + Application: &models.AppDetails{ + ID: "app-id-1", + Name: "my-app", + ConsumerOrgID: testConsumerOrgID, + }, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ConsumerDetails) + require.NotNil(t, data.ConsumerDetails.Application) + assert.Equal(t, "app-id-1", data.ConsumerDetails.Application.ID) + assert.Equal(t, "my-app", data.ConsumerDetails.Application.Name) + assert.Equal(t, testConsumerOrgID, data.ConsumerDetails.Application.ConsumerOrgID) + }, + }, + "summary consumerDetails publishedProduct and subscription are populated": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnConsumer2, + TransactionSummary: &Summary{ + Status: "Success", + ConsumerDetails: &models.ConsumerDetails{ + PublishedProduct: &models.Product{ID: "pp-id-1", Name: "published-product"}, + Subscription: &models.Subscription{ID: "sub-id-1", Name: "my-sub"}, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.ConsumerDetails) + require.NotNil(t, data.ConsumerDetails.PublishedProduct) + assert.Equal(t, "pp-id-1", data.ConsumerDetails.PublishedProduct.ID) + assert.Equal(t, "published-product", data.ConsumerDetails.PublishedProduct.Name) + require.NotNil(t, data.ConsumerDetails.Subscription) + assert.Equal(t, "sub-id-1", data.ConsumerDetails.Subscription.ID) + assert.Equal(t, "my-sub", data.ConsumerDetails.Subscription.Name) + }, + }, + // proxy path picks up apiServiceRevision from summary.API when both are set + "summary with proxy and API populates apiServiceRevision from API": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnProxyRev, + TransactionSummary: &Summary{ + Status: "Success", + Proxy: &Proxy{ID: "proxy-id-rev", Name: testAPIName}, + API: &models.APIDetails{ + ID: "api-id-rev", + APIServiceInstance: testRevisionUUID1, + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.APIServiceRevision) + assert.Equal(t, testRevisionUUID1, data.APIServiceRevision.ID) + }, + }, + // leg event data must not contain fields reserved for summary + // ProxyID takes priority over Source for api.id when both are set + "leg api.id uses ProxyID when set, not Source": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-proxyid-priority", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Source: "10.0.0.1", + ProxyID: "remoteApiId_abc123", + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + require.NotNil(t, data.API) + assert.Equal(t, "remoteApiId_abc123", data.API.ID) + assert.Equal(t, "10.0.0.1", data.Source) + }, + }, + "leg api.id falls back to Source when ProxyID is empty": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-source-fallback", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Source: "remoteApiId_abc123", + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + require.NotNil(t, data.API) + assert.Equal(t, "remoteApiId_abc123", data.API.ID) + }, + }, + // leg proxy is populated from SetProxy call + "leg proxy populated from SetProxy": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-leg-proxy", + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Source: "remoteApiId_abc123", + ProxyName: testAPIName, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + require.NotNil(t, data.Proxy) + assert.Equal(t, "remoteApiId_abc123", data.Proxy.ID) + assert.Equal(t, testAPIName, data.Proxy.Name) + + b, err := json.Marshal(data) + require.NoError(t, err) + assert.Contains(t, string(b), `"proxy":`) + }, + }, + // direction is lowercased regardless of what the agent passes + "leg direction is lowercased from Inbound": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-dir-lower", + TransactionEvent: &Event{ID: "0", Status: "Pass", Direction: "Inbound"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, "inbound", data.Direction) + }, + }, + "leg direction is lowercased from Outbound": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-dir-lower-2", + TransactionEvent: &Event{ID: "1", Status: "Pass", Direction: "Outbound"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionLegData) + require.True(t, ok) + assert.Equal(t, "outbound", data.Direction) + }, + }, + // summary proxy falls back to API details when Proxy is nil (controller path) + "summary proxy falls back to API when Proxy is nil": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "txn-proxy-api-fallback", + TransactionSummary: &Summary{ + Status: "Success", + API: &models.APIDetails{ + ID: "remoteApiId_abc123", + Name: "my-api-name", + }, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.Proxy) + assert.Equal(t, "abc123", data.Proxy.ID) + assert.Equal(t, "my-api-name", data.Proxy.Name) + }, + }, + // summary proxy serializes as nested object not flat dot-notation keys + "summary proxy is nested object": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: "txn-proxy-nested", + TransactionSummary: &Summary{ + Status: "Success", + Proxy: &Proxy{ID: "proxy-nested-id", Name: "proxy-nested-name"}, + }, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.Proxy) + assert.Equal(t, "proxy-nested-id", data.Proxy.ID) + assert.Equal(t, "proxy-nested-name", data.Proxy.Name) + + b, err := json.Marshal(data) + require.NoError(t, err) + s := string(b) + assert.Contains(t, s, `"proxy":{"id"`) + assert.NotContains(t, s, `"proxy.id"`) + assert.NotContains(t, s, `"proxy.name"`) + }, + }, + "leg event JSON must not contain summary-only fields": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnNoFields1, + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + b, err := json.Marshal(ie) + require.NoError(t, err) + s := string(b) + assert.NotContains(t, s, `"isInMetricEvent"`) + assert.NotContains(t, s, `"team"`) + assert.NotContains(t, s, `"apiServiceInstance"`) + assert.NotContains(t, s, `"entryPoint"`) + assert.NotContains(t, s, `"statusDetail"`) + assert.NotContains(t, s, `"apiServiceId"`) + assert.Contains(t, s, `"api.transaction.event"`) + assert.Contains(t, s, `"version":"4"`) + }, + }, + // summary event data must not contain fields reserved for leg or metric + "summary event JSON must not contain leg-only or metric-only fields": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnNoFields2, + TransactionSummary: &Summary{Status: "Success"}, + }, + orgID: testOrgID, + environmentID: testEnvID, + check: func(t *testing.T, ie *InsightsEvent) { + b, err := json.Marshal(ie) + require.NoError(t, err) + s := string(b) + assert.NotContains(t, s, `"isInMetricEvent"`) + assert.NotContains(t, s, `"legId"`) + assert.NotContains(t, s, `"direction"`) + assert.NotContains(t, s, `"uri"`) + assert.Contains(t, s, `"api.transaction.summary"`) + assert.Contains(t, s, `"version":"4"`) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), tc.logEvent, tc.orgID, tc.environmentID, nil, nil, reporter) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, ie) + if tc.check != nil { + tc.check(t, ie) + } + }) + } +} + +func TestBuildTransactionV2DataReporter(t *testing.T) { + reporter := ReporterInfo{ + AgentVersion: "2.1.0", + AgentType: "MyAgent", + AgentSDKVersion: "1.0.5", + AgentName: "my-agent", + } + + cases := map[string]struct { + logEvent LogEvent + }{ + "reporter fields populated on leg event": { + logEvent: LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnReporter, + TransactionEvent: &Event{ID: "0", Status: "Pass"}, + }, + }, + "reporter fields populated on summary event": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnReporter + "-sum", + TransactionSummary: &Summary{Status: "Success"}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), tc.logEvent, testOrgID, testEnvID, nil, nil, reporter) + require.NoError(t, err) + require.NotNil(t, ie) + + b, err := json.Marshal(ie) + require.NoError(t, err) + s := string(b) + assert.Contains(t, s, `"2.1.0"`) + assert.Contains(t, s, `"MyAgent"`) + assert.Contains(t, s, `"1.0.5"`) + assert.Contains(t, s, `"my-agent"`) + }) + } + + t.Run("observationDelta propagates to summary reporter", func(t *testing.T) { + r := ReporterInfo{ObservationDelta: 60000} + logEvent := LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnObsDelta, + TransactionSummary: &Summary{Status: "Success"}, + } + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), logEvent, testOrgID, testEnvID, nil, nil, r) + require.NoError(t, err) + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.Reporter) + assert.Equal(t, int64(60000), data.Reporter.ObservationDelta) + }) +} + +func TestBuildTransactionV2DataSummaryOptionalFields(t *testing.T) { + reporter := ReporterInfo{AgentVersion: "1.0.0", AgentType: "T", AgentSDKVersion: "0.1", AgentName: "n"} + + cases := map[string]struct { + logEvent LogEvent + check func(t *testing.T, data *TransactionSummaryData) + }{ + "assetResource populated when present": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnAsset, + TransactionSummary: &Summary{ + Status: "Success", + AssetResource: &models.AssetResource{ID: "asset-abc"}, + }, + }, + check: func(t *testing.T, data *TransactionSummaryData) { + require.NotNil(t, data.AssetResource) + assert.Equal(t, "asset-abc", data.AssetResource.ID) + }, + }, + "assetResource omitted when empty": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnAsset + "-empty", + TransactionSummary: &Summary{Status: "Success"}, + }, + check: func(t *testing.T, data *TransactionSummaryData) { + assert.Nil(t, data.AssetResource) + }, + }, + "apiServiceRevision populated from API.APIServiceInstance": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnRevision, + TransactionSummary: &Summary{ + Status: "Success", + API: &models.APIDetails{ + ID: "api-id", + Name: "api-name", + APIServiceInstance: "rev-id-123", + }, + }, + }, + check: func(t *testing.T, data *TransactionSummaryData) { + require.NotNil(t, data.APIServiceRevision) + assert.Equal(t, "rev-id-123", data.APIServiceRevision.ID) + }, + }, + "apiServiceRevision omitted when no revision id": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnRevision + "-empty", + TransactionSummary: &Summary{Status: "Success"}, + }, + check: func(t *testing.T, data *TransactionSummaryData) { + assert.Nil(t, data.APIServiceRevision) + b, err := json.Marshal(data) + require.NoError(t, err) + assert.NotContains(t, string(b), `"apiServiceRevision"`) + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), tc.logEvent, testOrgID, testEnvID, nil, nil, reporter) + require.NoError(t, err) + require.NotNil(t, ie) + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + tc.check(t, data) + }) + } +} + +func testDefaultRedaction(t *testing.T) redaction.Redactions { + t.Helper() + cfg := redaction.DefaultConfig() + r, err := cfg.SetupRedactions() + require.NoError(t, err) + return r +} + +func TestSetAPIServiceRevision(t *testing.T) { + rc := testDefaultRedaction(t) + + cases := map[string]struct { + revisionID string + withProxy bool + wantAPIInstance string + wantAPIName string + wantV2Revision string + }{ + "sets APIServiceInstance without proxy": { + revisionID: testRevisionUUID1, + withProxy: false, + wantAPIInstance: testRevisionUUID1, + }, + "sets APIServiceInstance after SetProxy preserves proxy name": { + revisionID: testRevisionUUID2, + withProxy: true, + wantAPIInstance: testRevisionUUID2, + wantAPIName: testProxyName, + }, + "propagates through to v2 event apiServiceRevision.id": { + revisionID: testRevisionUUID3, + withProxy: true, + wantV2Revision: testRevisionUUID3, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + b := NewTransactionSummaryBuilder(). + SetTransactionID("txn-rev"). + SetStatus(TxSummaryStatusSuccess, "200"). + SetDuration(100). + SetEntryPoint("http", "GET", "/path", testEntryPointHost). + SetRedactionConfig(rc) + if tc.withProxy { + b = b.SetProxy(testProxyID, testProxyName, 1) + } + le, err := b.SetAPIServiceRevision(tc.revisionID).Build() + require.NoError(t, err) + + require.NotNil(t, le.TransactionSummary.API) + if tc.wantAPIInstance != "" { + assert.Equal(t, tc.wantAPIInstance, le.TransactionSummary.API.APIServiceInstance) + } + if tc.wantAPIName != "" { + assert.Equal(t, tc.wantAPIName, le.TransactionSummary.API.Name) + } + if tc.wantV2Revision != "" { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), *le, testOrgID, testEnvID, nil, nil, ReporterInfo{}) + require.NoError(t, err) + data, ok := ie.Data.(*TransactionSummaryData) + require.True(t, ok) + require.NotNil(t, data.APIServiceRevision) + assert.Equal(t, tc.wantV2Revision, data.APIServiceRevision.ID) + } + }) + } +} + +// TestBuildTransactionV2DataLegExcludedFields verifies that leg events do not contain +// fields excluded from the leg v2 format. +func TestBuildTransactionV2DataLegExcludedFields(t *testing.T) { + reporter := ReporterInfo{AgentVersion: "1.0.0", AgentType: "T", AgentSDKVersion: "0.1", AgentName: "n"} + + logEvent := LogEvent{ + Type: TypeTransactionEvent, + TransactionID: testTxnExclLeg, + TransactionEvent: &Event{ + ID: "0", + Status: "Pass", + Protocol: &Protocol{ + URI: "/api/v1", + Method: "GET", + Status: 200, + }, + }, + } + + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), logEvent, testOrgID, testEnvID, nil, nil, reporter) + require.NoError(t, err) + + // Unmarshal data as a map so we can inspect only top-level keys of the data object. + b, err := json.Marshal(ie.Data) + require.NoError(t, err) + var dataKeys map[string]interface{} + require.NoError(t, json.Unmarshal(b, &dataKeys)) + + absentTopLevel := []string{"app", "team", "teamId", "apiServiceInstance", "isInMetricEvent", "revision", "uri", "method", "statusCode"} + for _, key := range absentTopLevel { + assert.NotContains(t, dataKeys, key, "leg data must not have top-level key %q", key) + } + + // URI/method/status must be nested inside the protocol object. + protoRaw, ok := dataKeys["protocol"] + require.True(t, ok, "leg data must have a protocol field") + proto, ok := protoRaw.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "/api/v1", proto["uri"]) + assert.Equal(t, "GET", proto["method"]) + assert.Equal(t, float64(200), proto["status"]) + assert.NotContains(t, proto, "statusCode", "protocol must use 'status' not 'statusCode'") +} + +// TestBuildTransactionV2DataSummaryExcludedFields verifies that summary events do not +// contain fields excluded from the summary v2 format. +func TestBuildTransactionV2DataSummaryExcludedFields(t *testing.T) { + reporter := ReporterInfo{AgentVersion: "1.0.0", AgentType: "T", AgentSDKVersion: "0.1", AgentName: "n"} + + cases := map[string]struct { + logEvent LogEvent + absentFields []string + }{ + "summary event excluded fields": { + logEvent: LogEvent{ + Type: TypeTransactionSummary, + TransactionID: testTxnExclSum, + TransactionSummary: &Summary{ + Status: "Success", + Proxy: &Proxy{ID: "p-1", Name: testAPIName}, + }, + }, + absentFields: []string{ + `"app"`, + `"isInMetricEvent"`, + `"team"`, + `"teamId"`, + `"apiServiceInstance"`, + `"proxy.apiServiceInstance"`, + `"proxy.revision"`, + `"application.id"`, + `"legId"`, + `"direction"`, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ie, err := BuildTransactionV2Data(log.NewFieldLogger(), tc.logEvent, testOrgID, testEnvID, nil, nil, reporter) + require.NoError(t, err) + b, err := json.Marshal(ie) + require.NoError(t, err) + s := string(b) + for _, field := range tc.absentFields { + assert.NotContains(t, s, field, "summary event must not contain field %s", field) + } + // proxy nested object must be present when proxy is set + assert.Contains(t, s, `"proxy":`) + assert.NotContains(t, s, `"proxy.id"`) + assert.NotContains(t, s, `"proxy.name"`) + }) + } +} + +func TestParseLegID(t *testing.T) { + tests := map[string]struct { + input string + want int + }{ + "plain integer zero": {input: "0", want: 0}, + "plain integer one": {input: "1", want: 1}, + "plain integer two": {input: "2", want: 2}, + "prefixed leg0": {input: "leg0", want: 0}, + "prefixed leg1": {input: "leg1", want: 1}, + "prefixed leg2": {input: "leg2", want: 2}, + "empty string": {input: "", want: 0}, + "non-numeric non-prefixed": {input: testTxnOutbound1, want: 0}, + "negative integer": {input: "-1", want: 0}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, parseLegID(tc.input)) + }) + } +} + +func TestFormatLegID(t *testing.T) { + tests := map[string]struct { + input string + want string + }{ + "empty string": {input: "", want: ""}, + "plain integer zero": {input: "0", want: "leg0"}, + "plain integer one": {input: "1", want: "leg1"}, + "already prefixed leg0": {input: "leg0", want: "leg0"}, + "already prefixed leg1": {input: "leg1", want: "leg1"}, + "non-numeric arbitrary ID": {input: testTxnOutbound1, want: testTxnOutbound1}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.want, formatLegID(tc.input)) + }) + } +} + +func TestBuildTransactionV2DataLegIDs(t *testing.T) { + tests := map[string]struct { + id string + parentID string + wantID string + wantPID string + }{ + "plain integer IDs (new agent style)": { + id: "0", parentID: "", wantID: "leg0", wantPID: "", + }, + "plain integer IDs outbound (new agent style)": { + id: "1", parentID: "0", wantID: "leg1", wantPID: "leg0", + }, + "prefixed IDs (legacy agent style)": { + id: "leg0", parentID: "", wantID: "leg0", wantPID: "", + }, + "prefixed IDs outbound (legacy agent style)": { + id: "leg1", parentID: "leg0", wantID: "leg1", wantPID: "leg0", + }, + "arbitrary parentID is passed through unchanged": { + id: "1", parentID: testTxnOutbound1, wantID: "leg1", wantPID: testTxnOutbound1, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + logEvent := LogEvent{ + Type: TypeTransactionEvent, + TransactionID: "txn-leg-id-test", + TransactionEvent: &Event{ + ID: tc.id, + ParentID: tc.parentID, + Status: "Pass", + }, + } + data, err := buildLegV2Data(logEvent, nil, nil, ReporterInfo{}) + require.NoError(t, err) + assert.Equal(t, tc.wantID, data.ID) + assert.Equal(t, tc.wantPID, data.ParentID) + }) + } +} + +func TestSetLegProxy(t *testing.T) { + cases := map[string]struct { + leg *TransactionLegData + proxyID string + proxyName string + wantNilProxy bool + wantProxyID string + wantProxyName string + }{ + "nil leg is a no-op": { + leg: nil, + proxyID: "id-1", + proxyName: "name-1", + wantNilProxy: true, + }, + "both IDs empty is a no-op": { + leg: &TransactionLegData{}, + wantNilProxy: true, + }, + "both IDs set populates proxy": { + leg: &TransactionLegData{}, + proxyID: "remoteApiId_ext-1", + proxyName: testAPIName, + wantProxyID: "remoteApiId_ext-1", + wantProxyName: testAPIName, + }, + "only proxyID set populates proxy": { + leg: &TransactionLegData{}, + proxyID: "remoteApiId_ext-2", + wantProxyID: "remoteApiId_ext-2", + }, + "only proxyName set populates proxy": { + leg: &TransactionLegData{}, + proxyName: "my-api-2", + wantProxyName: "my-api-2", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + SetLegProxy(tc.leg, tc.proxyID, tc.proxyName) + if tc.leg == nil { + return + } + if tc.wantNilProxy { + assert.Nil(t, tc.leg.Proxy) + return + } + assert.NotNil(t, tc.leg.Proxy) + assert.Equal(t, tc.wantProxyID, tc.leg.Proxy.ID) + assert.Equal(t, tc.wantProxyName, tc.leg.Proxy.Name) + }) + } +} + +func TestV4DataInterfaceMethods(t *testing.T) { + t.Run("TransactionLegData interface methods", func(t *testing.T) { + d := &TransactionLegData{TransactionID: testTxnIfaceLeg, LegID: 0} + assert.Equal(t, TypeTransactionEvent, d.GetType()) + assert.Equal(t, testTxnIfaceLeg, d.GetEventID()) + assert.Equal(t, (time.Time{}), d.GetStartTime()) + fields := d.GetLogFields() + assert.Equal(t, testTxnIfaceLeg, fields["transactionId"]) + }) + + t.Run("TransactionSummaryData interface methods", func(t *testing.T) { + d := &TransactionSummaryData{Status: "Success"} + assert.Equal(t, TypeTransactionSummary, d.GetType()) + assert.Empty(t, d.GetEventID()) + assert.Equal(t, (time.Time{}), d.GetStartTime()) + fields := d.GetLogFields() + assert.Equal(t, "Success", fields["status"]) + }) +} From bb065ca1fa833fccea44f3293461e5f3c093030e Mon Sep 17 00:00:00 2001 From: sbolosan <47189541+sbolosan@users.noreply.github.com> Date: Thu, 25 Jun 2026 05:17:41 -1000 Subject: [PATCH 21/22] APIGOV-31452 - resolve concurrent metric collector lock contention under burst load (#1053) --- pkg/agent/cache/cachevalidation.go | 4 +-- pkg/agent/cache/manager.go | 10 +++---- pkg/transaction/metric/metricscollector.go | 2 +- pkg/transaction/metric/util.go | 6 ++-- pkg/transaction/util/ownerresolver.go | 14 +++++++-- pkg/transaction/util/ownerresolver_test.go | 35 ++++++++++++++++++++++ 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/pkg/agent/cache/cachevalidation.go b/pkg/agent/cache/cachevalidation.go index 340070245..ff8d455ab 100644 --- a/pkg/agent/cache/cachevalidation.go +++ b/pkg/agent/cache/cachevalidation.go @@ -69,8 +69,8 @@ func (c *cacheManager) getCacheForKind(kind string) cache.Cache { // FlushKind empties the dedicated cache for the given resource kind. // Kinds without a dedicated cache are silently ignored. func (c *cacheManager) FlushKind(kind string) { - c.ApplyResourceReadLock() - defer c.ReleaseResourceReadLock() + c.resourceCacheReadLock.Lock() + defer c.resourceCacheReadLock.Unlock() c.logger.WithField("kind", kind).Debug("flushing cache for resource kind") diff --git a/pkg/agent/cache/manager.go b/pkg/agent/cache/manager.go index 0141bfe0d..280df20e2 100644 --- a/pkg/agent/cache/manager.go +++ b/pkg/agent/cache/manager.go @@ -158,7 +158,7 @@ type cacheManager struct { watchResourceMap cache.Cache idpMetadataMap cache.Cache sequenceCache cache.Cache - resourceCacheReadLock sync.Mutex + resourceCacheReadLock sync.RWMutex cacheLock sync.Mutex persistedCache cache.Cache teams cache.Cache @@ -402,17 +402,17 @@ func (c *cacheManager) GetSequence(watchTopicName string) int64 { } func (c *cacheManager) ApplyResourceReadLock() { - c.resourceCacheReadLock.Lock() + c.resourceCacheReadLock.RLock() } func (c *cacheManager) ReleaseResourceReadLock() { - c.resourceCacheReadLock.Unlock() + c.resourceCacheReadLock.RUnlock() } // Flush empties the persistent cache and all internal caches func (c *cacheManager) Flush() { - c.ApplyResourceReadLock() - defer c.ReleaseResourceReadLock() + c.resourceCacheReadLock.Lock() + defer c.resourceCacheReadLock.Unlock() c.logger.Debug("resetting the persistent cache") c.accessRequestMap.Flush() diff --git a/pkg/transaction/metric/metricscollector.go b/pkg/transaction/metric/metricscollector.go index 3e2fe90da..983104e0f 100644 --- a/pkg/transaction/metric/metricscollector.go +++ b/pkg/transaction/metric/metricscollector.go @@ -639,7 +639,7 @@ func (c *collector) createAPIDetail(api models.APIDetails) *models.APIResourceRe if svc != nil { ref.APIServiceID = svc.Metadata.ID } - ref.Owner = transutil.ResolveAPIOwner(api.ID, cacheManager) + ref.Owner = transutil.ResolveAPIOwnerFromInstance(svc) return ref } diff --git a/pkg/transaction/metric/util.go b/pkg/transaction/metric/util.go index 584ccc4c2..b285c597a 100644 --- a/pkg/transaction/metric/util.go +++ b/pkg/transaction/metric/util.go @@ -157,11 +157,11 @@ func buildAPIRef(api models.APIDetails) *models.APIResourceReference { Name: api.Name, } cacheManager := agent.GetCacheManager() - stripped := strings.TrimPrefix(api.ID, transutil.SummaryEventProxyIDPrefix) - if svc := cacheManager.GetAPIServiceWithAPIID(stripped); svc != nil { + svc := cacheManager.GetAPIServiceWithAPIID(strings.TrimPrefix(api.ID, transutil.SummaryEventProxyIDPrefix)) + if svc != nil { ref.APIServiceID = svc.Metadata.ID } - ref.Owner = transutil.ResolveAPIOwner(api.ID, cacheManager) + ref.Owner = transutil.ResolveAPIOwnerFromInstance(svc) return ref } diff --git a/pkg/transaction/util/ownerresolver.go b/pkg/transaction/util/ownerresolver.go index c85257543..62896c0bf 100644 --- a/pkg/transaction/util/ownerresolver.go +++ b/pkg/transaction/util/ownerresolver.go @@ -26,13 +26,21 @@ func ResolveAPIOwner(apiExternalID string, cacheManager cache.Manager) *models.O return &models.Owner{Type: "unknown"} } + return ResolveAPIOwnerFromInstance(ri) +} + +func ResolveAPIOwnerFromInstance(ri *v1.ResourceInstance) *models.Owner { + if ri == nil { + return &models.Owner{Type: "unknown"} + } + svc := &management.APIService{} if err := svc.FromInstance(ri); err != nil { return &models.Owner{Type: "unknown"} } if svc.Owner == nil { - logger.WithField("apiID", apiID).Trace("api service has no owner") + logger.WithField("apiName", ri.Name).Trace("api service has no owner") return &models.Owner{Type: "none"} } @@ -41,10 +49,10 @@ func ResolveAPIOwner(apiExternalID string, cacheManager cache.Manager) *models.O return &models.Owner{Type: "unknown"} } if svc.Owner.User != nil && svc.Owner.User.ID != "" { - logger.WithField("apiID", apiID).WithField("userGUID", svc.Owner.User.ID).Trace("resolved api owner as user (x-private team)") + logger.WithField("apiName", ri.Name).WithField("userGUID", svc.Owner.User.ID).Trace("resolved api owner as user (x-private team)") return &models.Owner{Type: "user", TeamGUID: svc.Owner.ID, UserGUID: svc.Owner.User.ID} } - logger.WithField("apiID", apiID).WithField("teamGUID", svc.Owner.ID).Trace("resolved api owner") + logger.WithField("apiName", ri.Name).WithField("teamGUID", svc.Owner.ID).Trace("resolved api owner") return &models.Owner{Type: "team", TeamGUID: svc.Owner.ID} } diff --git a/pkg/transaction/util/ownerresolver_test.go b/pkg/transaction/util/ownerresolver_test.go index e8b52aa66..fddbbb4ad 100644 --- a/pkg/transaction/util/ownerresolver_test.go +++ b/pkg/transaction/util/ownerresolver_test.go @@ -98,6 +98,41 @@ func TestResolveAPIOwner(t *testing.T) { } } +func TestResolveAPIOwnerFromInstance(t *testing.T) { + tests := map[string]struct { + ri *v1.ResourceInstance + expected *models.Owner + }{ + "nil resource instance returns unknown": { + ri: nil, + expected: &models.Owner{Type: "unknown"}, + }, + "api service with nil owner returns none": { + ri: apiServiceRI("api-1", nil), + expected: &models.Owner{Type: "none"}, + }, + "api service team owner with GUID": { + ri: apiServiceRI("api-2", &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID2}), + expected: &models.Owner{Type: "team", TeamGUID: testOwnerTeamGUID2}, + }, + "api service team owner with empty GUID": { + ri: apiServiceRI("api-3", &v1.Owner{Type: v1.TeamOwner, ID: ""}), + expected: &models.Owner{Type: "unknown"}, + }, + "api service x-private owner": { + ri: apiServiceRI("api-4", &v1.Owner{Type: v1.TeamOwner, ID: testOwnerTeamGUID1, User: &v1.OwnerUser{ID: testOwnerUserGUID}}), + expected: &models.Owner{Type: "user", TeamGUID: testOwnerTeamGUID1, UserGUID: testOwnerUserGUID}, + }, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, ResolveAPIOwnerFromInstance(tc.ri)) + }) + } +} + func TestResolveProductOwner(t *testing.T) { tests := map[string]struct { ref v1.EmbeddedReference From 3d3c2877ea4ceeb1f4f83fe15d2610d3c70a40b3 Mon Sep 17 00:00:00 2001 From: Shane Bolosan Date: Tue, 7 Jul 2026 13:56:02 -0700 Subject: [PATCH 22/22] APIGOV-32943 - rollback on invalid scope not on AS --- pkg/authz/oauth/clients/okta.go | 46 ++++++ pkg/authz/oauth/clients/okta_test.go | 78 ++++++++++ pkg/authz/oauth/oktaprovider.go | 52 ++++++- pkg/authz/oauth/oktaprovider_test.go | 205 ++++++++++++++++++++++++++- 4 files changed, 374 insertions(+), 7 deletions(-) diff --git a/pkg/authz/oauth/clients/okta.go b/pkg/authz/oauth/clients/okta.go index 0ad026ee2..e5d26bff8 100644 --- a/pkg/authz/oauth/clients/okta.go +++ b/pkg/authz/oauth/clients/okta.go @@ -385,6 +385,52 @@ func (o *Okta) DeleteApp(appID string) error { return nil } +// ActivatePolicy activates an Okta authorization server policy. A 404 is treated as success. +func (o *Okta) ActivatePolicy(authServerID, policyID string) error { + endpoint := fmt.Sprintf("%s/api/v1/authorizationServers/%s/policies/%s/lifecycle/activate", o.BaseURL, authServerID, policyID) + o.logger.WithField("authServerID", authServerID).WithField("policyID", policyID).WithField("endpoint", endpoint).Trace("activating Okta authorization server policy") + resp, err := o.doRequest(coreapi.POST, endpoint, nil) + if err != nil { + return err + } + if isStatus(resp.Code, http.StatusNotFound) { + return nil + } + if !isStatus(resp.Code, http.StatusOK, http.StatusNoContent) { + return o.unexpectedStatusError(coreapi.POST, endpoint, resp) + } + return nil +} + +// PolicyHasRuleForScope reports whether the given Okta authorization server policy has at least +// one rule that grants the specified scope. +func (o *Okta) PolicyHasRuleForScope(authServerID, policyID, scope string) (bool, error) { + endpoint := fmt.Sprintf("%s/api/v1/authorizationServers/%s/policies/%s/rules", o.BaseURL, authServerID, policyID) + o.logger. + WithField("authServerID", authServerID). + WithField("policyID", policyID). + WithField("scope", scope). + WithField("endpoint", endpoint). + Trace("checking Okta authorization server policy rules for scope") + + var rules []struct { + Conditions struct { + Scopes oktaPolicyRuleConditionScopes `json:"scopes"` + } `json:"conditions"` + } + if err := o.doGetJSON(endpoint, &rules); err != nil { + return false, err + } + for _, rule := range rules { + for _, s := range rule.Conditions.Scopes.Include { + if strings.TrimSpace(s) == scope { + return true, nil + } + } + } + return false, nil +} + // DeactivatePolicy deactivates an Okta authorization server policy. A 404 is treated as success. // DeactivatePolicy must be called before DeletePolicy. func (o *Okta) DeactivatePolicy(authServerID, policyID string) error { diff --git a/pkg/authz/oauth/clients/okta_test.go b/pkg/authz/oauth/clients/okta_test.go index c9f39623d..0b34f1a18 100644 --- a/pkg/authz/oauth/clients/okta_test.go +++ b/pkg/authz/oauth/clients/okta_test.go @@ -2,6 +2,7 @@ package clients import ( "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -239,6 +240,83 @@ func TestOktaRemoveClientFromPolicy(t *testing.T) { } } +func TestOktaActivatePolicy(t *testing.T) { + cases := map[string]struct { + code int + wantErr bool + }{ + nilOn200: {code: http.StatusOK, wantErr: false}, + nilOn204: {code: http.StatusNoContent, wantErr: false}, + notFoundAsSuccess: {code: http.StatusNotFound, wantErr: false}, + errForbidden: {code: http.StatusForbidden, wantErr: true}, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + }) + defer close() + assertOktaErr(t, client.ActivatePolicy(testAuthServerID, testPolicyID), tc.wantErr) + }) + } +} + +func TestOktaPolicyHasRuleForScope(t *testing.T) { + cases := map[string]struct { + code int + body string + scope string + wantHas bool + wantErr bool + }{ + "returns true when rule contains scope": { + code: http.StatusOK, + body: fmt.Sprintf(`[{"conditions":{"scopes":{"include":[%q,"write:api"]}}}]`, testRuleScope), + scope: testRuleScope, + wantHas: true, + }, + "returns false when rules list is empty": { + code: http.StatusOK, + body: `[]`, + scope: testRuleScope, + wantHas: false, + }, + "returns false when no rule contains scope": { + code: http.StatusOK, + body: `[{"conditions":{"scopes":{"include":["write:api"]}}}]`, + scope: testRuleScope, + wantHas: false, + }, + "trims whitespace when matching scope": { + code: http.StatusOK, + body: fmt.Sprintf(`[{"conditions":{"scopes":{"include":[" %s "]}}}]`, testRuleScope), + scope: testRuleScope, + wantHas: true, + }, + errForbidden: { + code: http.StatusForbidden, + body: `forbidden`, + wantErr: true, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + client, close := newTestOktaClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(tc.body)) + }) + defer close() + got, err := client.PolicyHasRuleForScope(testAuthServerID, testPolicyID, tc.scope) + assertOktaErr(t, err, tc.wantErr) + if !tc.wantErr { + assert.Equal(t, tc.wantHas, got) + } + }) + } +} + func TestOktaDeactivatePolicy(t *testing.T) { cases := map[string]struct { code int diff --git a/pkg/authz/oauth/oktaprovider.go b/pkg/authz/oauth/oktaprovider.go index da5b7acbc..d6fd873e4 100644 --- a/pkg/authz/oauth/oktaprovider.go +++ b/pkg/authz/oauth/oktaprovider.go @@ -223,16 +223,53 @@ func (i *okta) assignScopePolicy( } policyID, _ := createdPolicy["id"].(string) if err := oktaClient.CreatePolicyRule(authServerID, policyID, policyName, grantType, scope); err != nil { + i.cleanupOrphanedPolicy(oktaClient, authServerID, policyID) return err } i.logger.WithField("policyName", policyName).Trace("created policy and rule") return nil } - i.logger.WithField("policyName", policyName).Trace("policy found, assigning client") + i.logger.WithField("policyName", policyName).Trace("policy found, checking rule before assigning client") + + policyID, _ := policy["id"].(string) + hasRule, err := oktaClient.PolicyHasRuleForScope(authServerID, policyID, scope) + if err != nil { + return err + } + + if !hasRule { + i.logger.WithField("policyName", policyName).WithField("policyID", policyID).Debug("repairing policy, missing rule for scope") + status, _ := policy["status"].(string) + if err := i.ensurePolicyHasRule(oktaClient, authServerID, policyID, policyName, grantType, scope, status); err != nil { + return err + } + } + return oktaClient.AssignClientToPolicy(authServerID, policy, clientRes.GetClientID()) } +func (i *okta) ensurePolicyHasRule(oktaClient *clients.Okta, authServerID, policyID, policyName, grantType, scope, status string) error { + if err := oktaClient.CreatePolicyRule(authServerID, policyID, policyName, grantType, scope); err != nil { + return err + } + if status == "INACTIVE" { + return oktaClient.ActivatePolicy(authServerID, policyID) + } + return nil +} + +func (i *okta) cleanupOrphanedPolicy(oktaClient *clients.Okta, authServerID, policyID string) { + i.logger.WithField("policyID", policyID).Debug("removing orphaned policy after rule creation failure") + if err := oktaClient.DeactivatePolicy(authServerID, policyID); err != nil { + i.logger.WithField("policyID", policyID).WithError(err).Error("failed to deactivate orphaned policy, manual cleanup required in Okta") + return + } + if err := oktaClient.DeletePolicy(authServerID, policyID); err != nil { + i.logger.WithField("policyID", policyID).WithError(err).Error("failed to delete orphaned policy, manual cleanup required in Okta") + } +} + func (i *okta) handlePerScopePolicyUnassign( oktaClient *clients.Okta, clientID string, @@ -300,11 +337,20 @@ func (i *okta) deletePolicyIfEmpty(oktaClient *clients.Okta, authServerID, polic WithField("policyID", policyID). WithField("policyName", policyName). Debug("client list empty after removal, deleting policy") - + if err := oktaClient.DeactivatePolicy(authServerID, policyID); err != nil { return err } - return oktaClient.DeletePolicy(authServerID, policyID) + + if err := oktaClient.DeletePolicy(authServerID, policyID); err != nil { + i.logger. + WithField("policyID", policyID). + WithField("policyName", policyName). + WithError(err). + Error("failed to delete deactivated empty Okta policy, manual cleanup required in Okta") + return err + } + return nil } func (i *okta) validateExtraProperties(extraProps map[string]interface{}) error { diff --git a/pkg/authz/oauth/oktaprovider_test.go b/pkg/authz/oauth/oktaprovider_test.go index 95fa39304..bc854cc83 100644 --- a/pkg/authz/oauth/oktaprovider_test.go +++ b/pkg/authz/oauth/oktaprovider_test.go @@ -11,6 +11,7 @@ import ( "testing" coreapi "github.com/Axway/agent-sdk/pkg/api" + "github.com/Axway/agent-sdk/pkg/authz/oauth/clients" corecfg "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util/log" "github.com/stretchr/testify/assert" @@ -35,7 +36,10 @@ const ( oktaPolicyRulesEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/rules" oktaDeactivateEndpoint = "/api/v1/apps/app123/lifecycle/deactivate" oktaDeleteEndpoint = "/api/v1/apps/app123" - oktaPolicyDeactivateEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/lifecycle/deactivate" + oktaPolicyDeactivateEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/lifecycle/deactivate" + oktaPolicyActivateEndpoint = "/api/v1/authorizationServers/authorizationID/policies/pol-123/lifecycle/activate" + callCreateRule = "create-rule" + callActivate = "activate" testGroupName = "Marketplace" testGroupID = "grp-456" oktaGroupsEndpoint = "/api/v1/groups" @@ -118,6 +122,26 @@ func oktaPolicyGetHandler(policyID, name string, include []string) http.HandlerF } } +func oktaPolicyGetHandlerWithStatus(policyID, name, status string, include []string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + includeJSON, _ := json.Marshal(include) + _, _ = fmt.Fprintf(w, `{"id":%q,"name":%q,"status":%q,"conditions":{"clients":{"include":%s}}}`, policyID, name, status, string(includeJSON)) + } +} + +func oktaPolicyRulesHandler(scopes ...string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if len(scopes) == 0 { + _, _ = w.Write([]byte(`[]`)) + return + } + scopeJSON, _ := json.Marshal(scopes) + _, _ = fmt.Fprintf(w, `[{"conditions":{"scopes":{"include":%s}}}]`, string(scopeJSON)) + } +} + func oktaPolicyPutMustIncludeClientHandler(clientID string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -186,16 +210,64 @@ func TestOktaPostProcessClientRegistration(t *testing.T) { scopes: []string{testScope}, grantType: GrantTypeClientCredentials, routes: map[string]http.HandlerFunc{ - http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), - http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{}), - http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{}), + http.MethodGet + " " + oktaPolicyRulesEndpoint: oktaPolicyRulesHandler(testScope), + http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodGet + " " + oktaPolicyRulesEndpoint: 1, + http.MethodPut + " " + oktaPolicyEndpointByID: 1, + }, + }, + "repairs active policy missing rule before assigning client": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandler(oktaPolicyID, testPolicyName, []string{}), + http.MethodGet + " " + oktaPolicyRulesEndpoint: oktaPolicyRulesHandler(), + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"rule-1"}`)) + }, + http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), }, wantMinCalls: map[string]int{ http.MethodGet + " " + oktaPoliciesEndpointByID: 1, http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodGet + " " + oktaPolicyRulesEndpoint: 1, + http.MethodPost + " " + oktaPolicyRulesEndpoint: 1, http.MethodPut + " " + oktaPolicyEndpointByID: 1, }, }, + "reactivates and repairs deactivated policy missing rule before assigning client": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandlerWithStatus(oktaPolicyID, testPolicyName, "INACTIVE", []string{}), + http.MethodGet + " " + oktaPolicyRulesEndpoint: oktaPolicyRulesHandler(), + http.MethodPost + " " + oktaPolicyActivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"rule-1"}`)) + }, + http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), + }, + wantMinCalls: map[string]int{ + http.MethodGet + " " + oktaPoliciesEndpointByID: 1, + http.MethodGet + " " + oktaPolicyEndpointByID: 1, + http.MethodGet + " " + oktaPolicyRulesEndpoint: 1, + http.MethodPost + " " + oktaPolicyActivateEndpoint: 1, + http.MethodPost + " " + oktaPolicyRulesEndpoint: 1, + http.MethodPut + " " + oktaPolicyEndpointByID: 1, + }, + }, "no scopes results in no policy calls": { scopes: []string{}, grantType: GrantTypeClientCredentials, @@ -249,6 +321,31 @@ func TestOktaPostProcessClientRegistration(t *testing.T) { }, wantErr: true, }, + "orphaned policy cleaned up when CreatePolicyRule fails": { + scopes: []string{testScope}, + grantType: GrantTypeClientCredentials, + routes: map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler(nil), + http.MethodPost + " " + oktaPoliciesEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"id":%q,"name":%q}`, oktaPolicyID, testPolicyName) + }, + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + http.MethodPost + " " + oktaPolicyDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + http.MethodDelete + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }, + wantMinCalls: map[string]int{ + http.MethodPost + " " + oktaPolicyDeactivateEndpoint: 1, + http.MethodDelete + " " + oktaPolicyEndpointByID: 1, + }, + wantErr: true, + }, } for name, tc := range cases { @@ -695,6 +792,106 @@ func TestOktaDeactivateThenDelete(t *testing.T) { assert.Equal(t, []string{"deactivate", "delete"}, callOrder) } +func TestOktaPolicyRepairOrder(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + cases := map[string]struct { + policyStatus string + wantCallOrder []string + }{ + "active policy: rule created, no activate": { + policyStatus: "ACTIVE", + wantCallOrder: []string{callCreateRule}, + }, + "inactive policy: rule created then activated": { + policyStatus: "INACTIVE", + wantCallOrder: []string{callCreateRule, callActivate}, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + var mu sync.Mutex + callOrder := make([]string, 0, 2) + + routes := map[string]http.HandlerFunc{ + http.MethodGet + " " + oktaPoliciesEndpointByID: oktaPoliciesListHandler([]oktaPolicyItem{{ID: oktaPolicyID, Name: testPolicyName}}), + http.MethodGet + " " + oktaPolicyEndpointByID: oktaPolicyGetHandlerWithStatus(oktaPolicyID, testPolicyName, tc.policyStatus, []string{}), + http.MethodGet + " " + oktaPolicyRulesEndpoint: oktaPolicyRulesHandler(), + http.MethodPost + " " + oktaPolicyRulesEndpoint: func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callOrder = append(callOrder, callCreateRule) + mu.Unlock() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"rule-1"}`)) + }, + http.MethodPost + " " + oktaPolicyActivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callOrder = append(callOrder, callActivate) + mu.Unlock() + w.WriteHeader(http.StatusOK) + }, + http.MethodPut + " " + oktaPolicyEndpointByID: oktaPolicyPutMustIncludeClientHandler(testClientID), + } + ts, _ := newOktaScriptedServer(t, testAuthHeader, routes) + defer ts.Close() + + clientRes := &clientMetadata{ClientID: testClientID, Scope: []string{testScope}, GrantTypes: []string{GrantTypeClientCredentials}} + assert.NoError(t, oktaProvider.postProcessClientRegistration(clientRes, newIDPCredential(ts.URL), apiClient)) + assert.Equal(t, tc.wantCallOrder, callOrder) + }) + } +} + +func TestOktaCleanupOrphanedPolicy(t *testing.T) { + apiClient := coreapi.NewClient(nil, "") + oktaProvider := &okta{logger: log.NewFieldLogger()} + + cases := map[string]struct { + deactivateCode int + deleteCode int + wantDeleteCalled bool + }{ + "deactivate and delete both succeed": { + deactivateCode: http.StatusOK, + deleteCode: http.StatusNoContent, + wantDeleteCalled: true, + }, + "deactivate fails — delete is skipped": { + deactivateCode: http.StatusForbidden, + deleteCode: http.StatusNoContent, + wantDeleteCalled: false, + }, + "deactivate succeeds delete fails": { + deactivateCode: http.StatusOK, + deleteCode: http.StatusForbidden, + wantDeleteCalled: true, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + deleteCalled := false + routes := map[string]http.HandlerFunc{ + http.MethodPost + " " + oktaPolicyDeactivateEndpoint: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.deactivateCode) + }, + http.MethodDelete + " " + oktaPolicyEndpointByID: func(w http.ResponseWriter, r *http.Request) { + deleteCalled = true + w.WriteHeader(tc.deleteCode) + }, + } + ts, _ := newOktaScriptedServer(t, testAuthHeader, routes) + defer ts.Close() + + oktaClientObj := clients.New(apiClient, ts.URL, accessToken) + oktaProvider.cleanupOrphanedPolicy(oktaClientObj, "authorizationID", oktaPolicyID) + assert.Equal(t, tc.wantDeleteCalled, deleteCalled) + }) + } +} + func TestOktaPostProcessClientUnregisterAbortOnRemoveError(t *testing.T) { apiClient := coreapi.NewClient(nil, "") oktaProvider := &okta{logger: log.NewFieldLogger()}