From eec86df25831dba4d9d8864b5b584d2bbd99ae1d Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Wed, 2 Sep 2026 17:27:51 +0300 Subject: [PATCH 01/12] APIGOV-33010 provisioning webhook dispatch --- pkg/agent/handler/accessrequest.go | 51 ++++- pkg/agent/handler/credential.go | 48 +++++ pkg/agent/handler/managedapplication.go | 58 +++++- pkg/agent/handler/webhook.go | 170 ++++++++++++++++ pkg/agent/provisioning.go | 16 +- pkg/agent/provisioningwebhook/dispatch.go | 63 ++++++ pkg/apic/definitions/definitions.go | 6 +- pkg/apic/mockserviceclient.go | 1 + pkg/config/centralconfig.go | 90 +++++---- pkg/config/provisioningwebhookconfig.go | 193 +++++++++++++++++++ pkg/config/provisioningwebhookconfig_test.go | 125 ++++++++++++ pkg/config/webhookconfig.go | 18 +- pkg/config/webhookconfig_test.go | 9 +- 13 files changed, 792 insertions(+), 56 deletions(-) create mode 100644 pkg/agent/handler/webhook.go create mode 100644 pkg/agent/provisioningwebhook/dispatch.go create mode 100644 pkg/config/provisioningwebhookconfig.go create mode 100644 pkg/config/provisioningwebhookconfig_test.go diff --git a/pkg/agent/handler/accessrequest.go b/pkg/agent/handler/accessrequest.go index 59aa5009c..585012e24 100644 --- a/pkg/agent/handler/accessrequest.go +++ b/pkg/agent/handler/accessrequest.go @@ -7,10 +7,13 @@ import ( "time" agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/agent/provisioningwebhook" + "github.com/Axway/agent-sdk/pkg/api" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" prov "github.com/Axway/agent-sdk/pkg/apic/provisioning" + "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/util/log" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" @@ -35,6 +38,8 @@ type accessRequestHandler struct { encryptSchema encryptSchemaFunc customUnitHandler customUnitHandler retryCount int + webhookCfg config.ProvisioningWebhookEndpointConfig + webhookClient api.Client } func WithAccessRequestRetryCount(rc int) func(c *accessRequestHandler) { @@ -43,6 +48,15 @@ func WithAccessRequestRetryCount(rc int) func(c *accessRequestHandler) { } } +// WithAccessRequestProvisioningWebhook configures the webhook the handler calls instead of its own +// registered Provisioning implementation, when cfg.IsConfigured() +func WithAccessRequestProvisioningWebhook(cfg config.ProvisioningWebhookEndpointConfig, client api.Client) func(c *accessRequestHandler) { + return func(c *accessRequestHandler) { + c.webhookCfg = cfg + c.webhookClient = client + } +} + // NewAccessRequestHandler creates a Handler for Access Requests func NewAccessRequestHandler(prov prov.AccessProvisioner, cache agentcache.Manager, client client, customUnitHandler customUnitHandler, opts ...func(c *accessRequestHandler)) Handler { arh := &accessRequestHandler{ @@ -63,6 +77,9 @@ func (h *accessRequestHandler) ShouldHandle(ctx context.Context, event *proto.Ev if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XAgentDetails { return true } + if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { + return true + } if h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false } @@ -103,6 +120,17 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return nil } + if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { + ar := &management.AccessRequest{} + if err := ar.FromInstance(resource); err != nil { + return nil + } + if mirrorWebhookDetails(ar) { + return h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + } + return nil + } + log := getLoggerFromContext(ctx).WithComponent("accessRequestHandler") defer log.Trace("finished processing request") ctx = setLoggerInContext(ctx, log) @@ -171,6 +199,11 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) *management.AccessRequest { log := getLoggerFromContext(ctx) + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationProvision) { + return ar + } + app, err := h.getManagedApp(ctx, ar) if err != nil { log.WithError(err).Error("error getting managed app") @@ -201,6 +234,12 @@ func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.Acc updateDataFromEnumMap(ar.Spec.Data, ard.Spec.Schema) + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationProvision, *req)) + markWebhookDispatched(ar, webhookOperationProvision) + return ar + } + data := map[string]interface{}{} status, accessData := h.provision(req) @@ -292,6 +331,10 @@ func (h *accessRequestHandler) onError(_ context.Context, ar *management.AccessR func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.AccessRequest) { log := getLoggerFromContext(ctx) + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationDeprovision) { + return + } + app, err := h.getManagedApp(ctx, ar) if err != nil { log.WithError(err).Error("error getting managed app") @@ -309,8 +352,14 @@ func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.Ac return } - status := h.prov.AccessRequestDeprovision(req) + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationDeprovision, *req)) + markWebhookDispatched(ar, webhookOperationDeprovision) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + return + } + status := h.prov.AccessRequestDeprovision(req) if status.GetStatus() == prov.Success { h.client.UpdateResourceFinalizer(ri, arFinalizer, "", false) h.cache.DeleteAccessRequest(ri.Metadata.ID) diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index 27407263b..a8799ea46 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -8,12 +8,15 @@ import ( "strings" "time" + "github.com/Axway/agent-sdk/pkg/agent/provisioningwebhook" + "github.com/Axway/agent-sdk/pkg/api" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" prov "github.com/Axway/agent-sdk/pkg/apic/provisioning" "github.com/Axway/agent-sdk/pkg/apic/provisioning/idp" "github.com/Axway/agent-sdk/pkg/authz/oauth" + "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/util/log" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" @@ -38,6 +41,8 @@ type credentials struct { encryptSchema encryptSchemaFunc idpProviderRegistry oauth.IdPRegistry retryCount int + webhookCfg config.ProvisioningWebhookEndpointConfig + webhookClient api.Client } func WithCredentialRetryCount(rc int) func(c *credentials) { @@ -46,6 +51,15 @@ func WithCredentialRetryCount(rc int) func(c *credentials) { } } +// WithCredentialProvisioningWebhook configures the webhook the handler calls instead of its own +// registered Provisioning implementation, when cfg.IsConfigured() +func WithCredentialProvisioningWebhook(cfg config.ProvisioningWebhookEndpointConfig, client api.Client) func(c *credentials) { + return func(c *credentials) { + c.webhookCfg = cfg + c.webhookClient = client + } +} + // encryptSchemaFunc func signature for encryptSchema type encryptSchemaFunc func(schema, credData map[string]interface{}, key, alg, hash string) (map[string]interface{}, error) @@ -66,6 +80,9 @@ func NewCredentialHandler(prov credProv, client client, providerRegistry oauth.I func (h *credentials) ShouldHandle(ctx context.Context, event *proto.Event) bool { action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { + return true + } if action == proto.Event_DELETED || h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false } @@ -84,6 +101,14 @@ func (h *credentials) Handle(ctx context.Context, meta *proto.EventMeta, resourc return nil } + action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { + if mirrorWebhookDetails(cr) { + return h.client.CreateSubResource(cr.ResourceMeta, cr.SubResources) + } + return nil + } + if ok := isStatusFound(cr.Status); !ok { logger.Debug("could not handle credential request as it did not have a status subresource") return nil @@ -199,6 +224,11 @@ func (h *credentials) shouldProcessUpdating(cr *management.Credential) []prov.Cr func (h *credentials) onDeleting(ctx context.Context, cred *management.Credential) { logger := getLoggerFromContext(ctx) + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationDeprovision) { + return + } + crd, err := h.getCRD(ctx, cred) if err != nil { logger.WithError(err).Error("error getting credential request definition") @@ -219,6 +249,13 @@ func (h *credentials) onDeleting(ctx context.Context, cred *management.Credentia return } + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationDeprovision, provCreds)) + markWebhookDispatched(cred, webhookOperationDeprovision) + h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + return + } + status := h.prov.CredentialDeprovision(provCreds) h.deprovisionPostProcess(status, provCreds, logger, ctx, cred, app) @@ -272,6 +309,11 @@ func (h *credentials) deprovisionPostProcess(status prov.RequestStatus, provCred func (h *credentials) onPending(ctx context.Context, cred *management.Credential) *management.Credential { // check the application status logger := getLoggerFromContext(ctx) + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationProvision) { + return cred + } + app, crd, shouldReturn := h.provisionPreProcess(ctx, cred) if shouldReturn { return cred @@ -290,6 +332,12 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential return cred } + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationProvision, provCreds)) + markWebhookDispatched(cred, webhookOperationProvision) + return cred + } + status, credentialData := h.provision(provCreds) h.provisionPostProcess(status, credentialData, app, crd, provCreds, cred) diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index 3ba568049..a6a170d72 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -7,11 +7,14 @@ import ( "time" agentcache "github.com/Axway/agent-sdk/pkg/agent/cache" + "github.com/Axway/agent-sdk/pkg/agent/provisioningwebhook" + "github.com/Axway/agent-sdk/pkg/api" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1" 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/config" "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" ) @@ -27,11 +30,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 + webhookCfg config.ProvisioningWebhookEndpointConfig + webhookClient api.Client } func WithManagedAppRetryCount(rc int) func(c *managedApplication) { @@ -46,6 +51,15 @@ func WithManagedAppIDPRegistry(registry oauth.IdPRegistry) func(c *managedApplic } } +// WithManagedAppProvisioningWebhook configures the webhook the handler calls instead of its own +// registered Provisioning implementation, when cfg.IsConfigured() +func WithManagedAppProvisioningWebhook(cfg config.ProvisioningWebhookEndpointConfig, client api.Client) func(c *managedApplication) { + return func(c *managedApplication) { + c.webhookCfg = cfg + c.webhookClient = client + } +} + func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentcache.Manager, client client, opts ...func(c *managedApplication)) Handler { ma := &managedApplication{ prov: prov, @@ -63,6 +77,9 @@ func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentc func (h *managedApplication) ShouldHandle(ctx context.Context, event *proto.Event) bool { action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { + return true + } if h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false } @@ -82,6 +99,14 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, return nil } + action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { + if mirrorWebhookDetails(app) { + return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + } + return nil + } + if ok := isStatusFound(app.Status); !ok { log.Debug("could not handle application request as it did not have a status subresource") return nil @@ -114,6 +139,16 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, func (h *managedApplication) onPending(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) error { log := getLoggerFromContext(ctx) + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + if webhookDispatchedFor(app, webhookOperationProvision) { + return nil + } + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationProvision, pma)) + markWebhookDispatched(app, webhookOperationProvision) + return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + } + status := h.provision(pma) app.Status = prov.NewStatusReason(status) @@ -171,12 +206,25 @@ func (h *managedApplication) provision(pma provManagedApp) prov.RequestStatus { func (h *managedApplication) onDeleting(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) { log := getLoggerFromContext(ctx) + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(app, webhookOperationDeprovision) { + return + } + 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 } + + if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationDeprovision, pma)) + markWebhookDispatched(app, webhookOperationDeprovision) + h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + return + } + status := h.prov.ApplicationRequestDeprovision(pma) if status.GetStatus() == prov.Success { ri, _ := app.AsInstance() diff --git a/pkg/agent/handler/webhook.go b/pkg/agent/handler/webhook.go new file mode 100644 index 000000000..a64c6a4d1 --- /dev/null +++ b/pkg/agent/handler/webhook.go @@ -0,0 +1,170 @@ +package handler + +import ( + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/util" +) + +// webhookDispatchDetailKey is the x-agent-details key that records which operation ("provision" or +// "deprovision") the agent last dispatched to the provisioning webhook for this resource. Reusing +// x-agent-details (rather than a new subresource) is safe here because the agent is the only writer of this +// key - the client's webhook, per docs/discovery/provisioning-webhook.md step 2, is scoped to write only the +// separate x-webhook-details subresource, so there's no risk of it clobbering this marker. +const webhookDispatchDetailKey = "provisioningWebhookDispatched" + +const ( + webhookOperationProvision = "provision" + webhookOperationDeprovision = "deprovision" +) + +// subResourceCarrier is satisfied by any apiserver resource instance type (ManagedApplication, AccessRequest, +// Credential, ...) - matches the generic GetSubResource/SetSubResource pair they all get from ResourceMeta, +// which is also what the unexported util.handler interface requires. +type subResourceCarrier interface { + GetSubResource(key string) interface{} + SetSubResource(key string, resource interface{}) +} + +// webhookDispatchedFor returns true if the resource's x-agent-details already record that operation was +// dispatched to the provisioning webhook - used to avoid re-dispatching on event redelivery (Central may +// redeliver an event for a resource for reasons unrelated to anything this handler wrote). +func webhookDispatchedFor(h subResourceCarrier, operation string) bool { + v, _ := util.GetAgentDetailsValue(h, webhookDispatchDetailKey) + return v == operation +} + +// markWebhookDispatched records, in the resource's x-agent-details, that operation was just dispatched to +// the provisioning webhook. No resource status is written for this - the resource is left exactly as it was +// (still Pending, or still Deleting) until something (not yet built) reports real completion; see +// docs/discovery/provisioning-webhook.md. +func markWebhookDispatched(h subResourceCarrier, operation string) { + _ = util.SetAgentDetailsKey(h, webhookDispatchDetailKey, operation) +} + +// mirrorWebhookDetails copies the resource's x-webhook-details subresource into its x-agent-details, so +// existing code (traceability lookups, etc.) that only knows how to read x-agent-details keeps working once +// a provisioning webhook is configured - the webhook itself is scoped to write only x-webhook-details, not +// x-agent-details directly. Returns false if there is nothing to mirror. +func mirrorWebhookDetails(h subResourceCarrier) bool { + webhookDetails, ok := h.GetSubResource(defs.XWebhookDetails).(map[string]interface{}) + if !ok || len(webhookDetails) == 0 { + return false + } + + agentDetails := util.GetAgentDetails(h) + if agentDetails == nil { + agentDetails = map[string]interface{}{} + } + for k, v := range webhookDetails { + agentDetails[k] = v + } + util.SetAgentDetails(h, agentDetails) + return true +} + +// webhookApplicationRequest is the payload sent to the configured provisioning webhook for a ManagedApplication event +type webhookApplicationRequest struct { + Operation string `json:"operation"` + ID string `json:"id"` + ManagedApplicationName string `json:"managedApplicationName"` + TeamName string `json:"teamName"` + ConsumerOrgID string `json:"consumerOrgId,omitempty"` + AgentDetails map[string]interface{} `json:"agentDetails,omitempty"` +} + +func newWebhookApplicationRequest(operation string, a provManagedApp) webhookApplicationRequest { + return webhookApplicationRequest{ + Operation: operation, + ID: a.id, + ManagedApplicationName: a.managedAppName, + TeamName: a.teamName, + ConsumerOrgID: a.consumerOrgID, + AgentDetails: a.data, + } +} + +// webhookQuota mirrors the fields of prov.Quota needed by the webhook contract +type webhookQuota struct { + Limit int64 `json:"limit"` + Interval string `json:"interval"` +} + +// webhookAccessRequest is the payload sent to the configured provisioning webhook for an AccessRequest event +type webhookAccessRequest struct { + Operation string `json:"operation"` + ID string `json:"id"` + ReferencedID string `json:"referencedId,omitempty"` + ManagedApplicationName string `json:"managedApplicationName"` + IsTransferring bool `json:"isTransferring"` + RequestData map[string]interface{} `json:"requestData,omitempty"` + ProvisioningData interface{} `json:"provisioningData,omitempty"` + AccessDetails map[string]interface{} `json:"accessDetails,omitempty"` + ReferencedAccessDetails map[string]interface{} `json:"referencedAccessDetails,omitempty"` + ApplicationDetails map[string]interface{} `json:"applicationDetails,omitempty"` + InstanceDetails map[string]interface{} `json:"instanceDetails,omitempty"` + Quota *webhookQuota `json:"quota,omitempty"` +} + +func newWebhookAccessRequest(operation string, r provAccReq) webhookAccessRequest { + req := webhookAccessRequest{ + Operation: operation, + ID: r.id, + ReferencedID: r.refID, + ManagedApplicationName: r.managedApp, + IsTransferring: r.refID != "", + RequestData: r.requestData, + ProvisioningData: r.provData, + AccessDetails: r.accessDetails, + ReferencedAccessDetails: r.refAccessDetails, + ApplicationDetails: r.appDetails, + InstanceDetails: r.instanceDetails, + } + if r.quota != nil { + req.Quota = &webhookQuota{Limit: r.quota.GetLimit(), Interval: r.quota.GetIntervalString()} + } + return req +} + +// webhookCredentialRequest is the payload sent to the configured provisioning webhook for a Credential event +type webhookCredentialRequest struct { + Operation string `json:"operation"` + ID string `json:"id"` + Name string `json:"name"` + ManagedApplicationName string `json:"managedApplicationName"` + CredentialType string `json:"credentialType"` + CredentialAction int `json:"credentialAction"` + CredentialData map[string]interface{} `json:"credentialData,omitempty"` + CredentialDetails map[string]interface{} `json:"credentialDetails,omitempty"` + ApplicationDetails map[string]interface{} `json:"applicationDetails,omitempty"` + CredentialSchema map[string]interface{} `json:"credentialSchema,omitempty"` + CredentialProvisionSchema map[string]interface{} `json:"credentialProvisionSchema,omitempty"` + CredentialSchemaDetails map[string]interface{} `json:"credentialSchemaDetails,omitempty"` + ProvisionMode string `json:"provisionMode,omitempty"` + ExpirationDays int `json:"expirationDays,omitempty"` + IDPClientID string `json:"idpClientId,omitempty"` + IDPTokenEndpoint string `json:"idpTokenEndpoint,omitempty"` +} + +func newWebhookCredentialRequest(operation string, c *provCreds) webhookCredentialRequest { + req := webhookCredentialRequest{ + Operation: operation, + ID: c.id, + Name: c.name, + ManagedApplicationName: c.managedApp, + CredentialType: c.credType, + CredentialAction: int(c.credAction), + CredentialData: c.credData, + CredentialDetails: c.credDetails, + ApplicationDetails: c.appDetails, + CredentialSchema: c.credSchema, + CredentialProvisionSchema: c.credProvSchema, + CredentialSchemaDetails: c.credSchemaDetails, + ProvisionMode: c.provisionMode, + ExpirationDays: c.days, + } + if c.IsIDPCredential() { + req.IDPClientID = c.GetIDPCredentialData().GetClientID() + req.IDPTokenEndpoint = c.GetIDPProvider().GetTokenEndpoint() + } + return req +} diff --git a/pkg/agent/provisioning.go b/pkg/agent/provisioning.go index ae874f99f..31f6c2f2c 100644 --- a/pkg/agent/provisioning.go +++ b/pkg/agent/provisioning.go @@ -2,6 +2,7 @@ package agent import ( "github.com/Axway/agent-sdk/pkg/agent/handler" + "github.com/Axway/agent-sdk/pkg/api" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1" "github.com/Axway/agent-sdk/pkg/apic/provisioning" @@ -10,6 +11,12 @@ import ( "github.com/Axway/agent-sdk/pkg/util" ) +// provisioningWebhookClient - creates the HTTP client used to call configured provisioning webhooks, +// respecting the agent's TLS/proxy/timeout settings +func provisioningWebhookClient() api.Client { + return api.NewClient(agent.cfg.GetTLSConfig(), agent.cfg.GetProxyURL(), api.WithTimeout(agent.cfg.GetClientTimeout())) +} + var supportedIDPGrantTypes = map[string]bool{ oauth.GrantTypeClientCredentials: true, oauth.GrantTypeAuthorizationCode: true} @@ -609,7 +616,8 @@ func registerApplicationProvisioner(provisioner interface{}) { management.ManagedApplicationGVK().Kind, handler.NewManagedApplicationHandler(appProv, agent.cacheManager, agent.apicClient, handler.WithManagedAppRetryCount(agent.cfg.GetProvisioningRetryCount()), - handler.WithManagedAppIDPRegistry(registry)), + handler.WithManagedAppIDPRegistry(registry), + handler.WithManagedAppProvisioningWebhook(agent.cfg.GetProvisioningWebhookConfig().GetManagedApplicationWebhook(), provisioningWebhookClient())), ) } } @@ -630,7 +638,8 @@ func registerAccessProvisioner(provisioner interface{}) { agent.proxyResourceHandler.RegisterTargetHandler( management.AccessRequestGVK().Kind, handler.NewAccessRequestHandler(arProv, agent.cacheManager, agent.apicClient, agent.customUnitHandler, - handler.WithAccessRequestRetryCount(agent.cfg.GetProvisioningRetryCount())), + handler.WithAccessRequestRetryCount(agent.cfg.GetProvisioningRetryCount()), + handler.WithAccessRequestProvisioningWebhook(agent.cfg.GetProvisioningWebhookConfig().GetAccessRequestWebhook(), provisioningWebhookClient())), ) } } @@ -642,7 +651,8 @@ func registerCredentialProvisioner(provisioner interface{}) { agent.proxyResourceHandler.RegisterTargetHandler( management.CredentialGVK().Kind, handler.NewCredentialHandler(credProv, agent.apicClient, registry, - handler.WithCredentialRetryCount(agent.cfg.GetProvisioningRetryCount())), + handler.WithCredentialRetryCount(agent.cfg.GetProvisioningRetryCount()), + handler.WithCredentialProvisioningWebhook(agent.cfg.GetProvisioningWebhookConfig().GetCredentialWebhook(), provisioningWebhookClient())), ) } } diff --git a/pkg/agent/provisioningwebhook/dispatch.go b/pkg/agent/provisioningwebhook/dispatch.go new file mode 100644 index 000000000..4dceb376e --- /dev/null +++ b/pkg/agent/provisioningwebhook/dispatch.go @@ -0,0 +1,63 @@ +package provisioningwebhook + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/Axway/agent-sdk/pkg/api" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/util/log" +) + +var logger = log.NewFieldLogger().WithComponent("provisioningWebhook").WithPackage("agent") + +// Dispatch sends payload as the JSON body of a POST request to the webhook configured in cfg, applying +// whichever auth method is configured, using client to make the call. The agent does not wait for the +// client's webhook to actually finish processing - this only reports (via the returned error and logging) +// whether the HTTP call itself was accepted, not whether provisioning/deprovisioning completed. +func Dispatch(client api.Client, cfg config.ProvisioningWebhookEndpointConfig, payload interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + logger.WithError(err).Error("failed to build provisioning webhook payload") + return err + } + + headers := map[string]string{"Content-Type": "application/json"} + for k, v := range cfg.GetWebhookHeaders() { + headers[k] = v + } + applyAuth(cfg, headers) + + resp, err := client.Send(api.Request{ + Method: api.POST, + URL: cfg.GetURL(), + Headers: headers, + Body: body, + }) + if err != nil { + logger.WithError(err).Error("failed to call provisioning webhook") + return err + } + + if resp.Code < 200 || resp.Code >= 300 { + err := fmt.Errorf("provisioning webhook returned status %d", resp.Code) + logger.WithError(err).Error("provisioning webhook call failed") + return err + } + + logger.Trace("dispatched to provisioning webhook") + return nil +} + +func applyAuth(cfg config.ProvisioningWebhookEndpointConfig, headers map[string]string) { + switch cfg.GetAuthType() { + case config.ProvisioningWebhookAuthBasic: + creds := base64.StdEncoding.EncodeToString([]byte(cfg.GetUsername() + ":" + cfg.GetPassword())) + headers["Authorization"] = "Basic " + creds + case config.ProvisioningWebhookAuthAPIKey: + headers[cfg.GetAPIKeyHeader()] = cfg.GetAPIKeyValue() + case config.ProvisioningWebhookAuthBearer: + headers["Authorization"] = "Bearer " + cfg.GetSecret() + } +} diff --git a/pkg/apic/definitions/definitions.go b/pkg/apic/definitions/definitions.go index f9400b45a..04263d4ff 100644 --- a/pkg/apic/definitions/definitions.go +++ b/pkg/apic/definitions/definitions.go @@ -23,7 +23,11 @@ type PlatformTeam struct { // Constants for attributes const ( - XAgentDetails = "x-agent-details" + XAgentDetails = "x-agent-details" + // XWebhookDetails is the subresource an on-prem provisioning webhook is scoped to write directly + // (see docs/discovery/provisioning-webhook.md). agent-sdk mirrors it into XAgentDetails whenever it + // changes, so existing traceability code (which only reads XAgentDetails) needs no changes. + XWebhookDetails = "x-webhook-details" XSubResourceHashes = "x-subresource-hashes" AttrPreviousAPIServiceRevisionID = "prevAPIServiceRevisionID" AttrPreviousAPIServiceInstanceID = "prevAPIServiceInstanceID" diff --git a/pkg/apic/mockserviceclient.go b/pkg/apic/mockserviceclient.go index 661781e47..e5d4eae86 100644 --- a/pkg/apic/mockserviceclient.go +++ b/pkg/apic/mockserviceclient.go @@ -18,6 +18,7 @@ import ( // MockHTTPClient so the caller can use it directly if needed, as it is not available directly from ServiceClient in other packages func GetTestServiceClient() (*ServiceClient, *api.MockHTTPClient) { webhook := &corecfg.WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "http://foo.bar", Headers: "Header=contentType,Value=application/json", Secret: "", diff --git a/pkg/config/centralconfig.go b/pkg/config/centralconfig.go index 019cfd4fc..7d5837d8f 100644 --- a/pkg/config/centralconfig.go +++ b/pkg/config/centralconfig.go @@ -232,6 +232,7 @@ type CentralConfig interface { SetManagedEnvironments([]string) GetManagedEnvironments() []string GetProvisioningRetryCount() int + GetProvisioningWebhookConfig() ProvisioningWebhookConfig IsInstanceValidationEnabled() bool GetRootTagsToStrip() []string GetRegularEventWorkerCount() int @@ -245,41 +246,42 @@ type CentralConfiguration struct { IConfigValidator AgentType AgentType RegionSettings regionalSettings - Region Region `config:"region"` - TenantID string `config:"organizationID"` - TeamName string `config:"team"` - APICDeployment string `config:"deployment"` - Environment string `config:"environment"` - EnvironmentID string `config:"environmentID"` - AgentName string `config:"agentName"` - URL string `config:"url"` - SingleURL string `config:"platformSingleURL"` - PlatformURL string `config:"platformURL"` - APIServerVersion string `config:"apiServerVersion"` - TagsToPublish string `config:"additionalTags"` - AppendEnvironmentToTitle bool `config:"appendEnvironmentToTitle"` - MigrationSettings MigrationConfig `config:"migration"` - Auth AuthConfig `config:"auth"` - TLS TLSConfig `config:"ssl"` - PollInterval time.Duration `config:"pollInterval"` - ReportActivityFrequency time.Duration `config:"reportActivityFrequency"` - ClientTimeout time.Duration `config:"clientTimeout"` - PageSize int `config:"pageSize"` - APIClientWorkers int `config:"apiClientWorkers"` - APIValidationCronSchedule string `config:"apiValidationCronSchedule"` - APIServiceRevisionPattern string `config:"apiServiceRevisionPattern"` - ProxyURL string `config:"proxyUrl"` - UsageReporting UsageReportingConfig `config:"usageReporting"` - MetricReporting MetricReportingConfig `config:"metricReporting"` - ErrorSamplingEnabled bool `config:"errorSamplingEnabled"` - GRPCCfg GRPCConfig `config:"grpc"` - CacheStoragePath string `config:"cacheStoragePath"` - CacheStorageInterval time.Duration `config:"cacheStorageInterval"` - CredentialConfig CredentialConfig `config:"credential"` - ProvisioningRetryCount int `config:"provisioningRetryCount"` - InstanceValidatorEnabled bool `config:"instanceValidatorEnabled"` - JobExecutionTimeout time.Duration `config:"jobTimeout"` - RootTagsToStrip []string `config:"rootTagsToStrip"` + Region Region `config:"region"` + TenantID string `config:"organizationID"` + TeamName string `config:"team"` + APICDeployment string `config:"deployment"` + Environment string `config:"environment"` + EnvironmentID string `config:"environmentID"` + AgentName string `config:"agentName"` + URL string `config:"url"` + SingleURL string `config:"platformSingleURL"` + PlatformURL string `config:"platformURL"` + APIServerVersion string `config:"apiServerVersion"` + TagsToPublish string `config:"additionalTags"` + AppendEnvironmentToTitle bool `config:"appendEnvironmentToTitle"` + MigrationSettings MigrationConfig `config:"migration"` + Auth AuthConfig `config:"auth"` + TLS TLSConfig `config:"ssl"` + PollInterval time.Duration `config:"pollInterval"` + ReportActivityFrequency time.Duration `config:"reportActivityFrequency"` + ClientTimeout time.Duration `config:"clientTimeout"` + PageSize int `config:"pageSize"` + APIClientWorkers int `config:"apiClientWorkers"` + APIValidationCronSchedule string `config:"apiValidationCronSchedule"` + APIServiceRevisionPattern string `config:"apiServiceRevisionPattern"` + ProxyURL string `config:"proxyUrl"` + UsageReporting UsageReportingConfig `config:"usageReporting"` + MetricReporting MetricReportingConfig `config:"metricReporting"` + ErrorSamplingEnabled bool `config:"errorSamplingEnabled"` + GRPCCfg GRPCConfig `config:"grpc"` + CacheStoragePath string `config:"cacheStoragePath"` + CacheStorageInterval time.Duration `config:"cacheStorageInterval"` + CredentialConfig CredentialConfig `config:"credential"` + ProvisioningRetryCount int `config:"provisioningRetryCount"` + ProvisioningWebhook ProvisioningWebhookConfig `config:"provisioningWebhook"` + InstanceValidatorEnabled bool `config:"instanceValidatorEnabled"` + JobExecutionTimeout time.Duration `config:"jobTimeout"` + RootTagsToStrip []string `config:"rootTagsToStrip"` RegularEventWorkerCount int `config:"regularEventWorkerCount"` ProvisioningEventWorkerCount int `config:"provisioningEventWorkerCount"` @@ -331,8 +333,9 @@ func NewCentralConfig(agentType AgentType) CentralConfig { GRPCCfg: GRPCConfig{ Enabled: true, }, - MigrationSettings: newMigrationConfig(), - CredentialConfig: newCredentialConfig(), + MigrationSettings: newMigrationConfig(), + CredentialConfig: newCredentialConfig(), + ProvisioningWebhook: newProvisioningWebhookConfig(), } } @@ -730,6 +733,11 @@ func (c *CentralConfiguration) GetProvisioningRetryCount() int { return c.ProvisioningRetryCount } +// GetProvisioningWebhookConfig - Returns the on-prem provisioning webhook config +func (c *CentralConfiguration) GetProvisioningWebhookConfig() ProvisioningWebhookConfig { + return c.ProvisioningWebhook +} + func (c *CentralConfiguration) IsInstanceValidationEnabled() bool { return c.InstanceValidatorEnabled } @@ -815,6 +823,12 @@ func (c *CentralConfiguration) ValidateCfg() (err error) { c.validateConfig() c.Auth.validate() + if c.ProvisioningWebhook != nil { + if err := c.ProvisioningWebhook.ValidateConfig(); err != nil { + exception.Throw(err) + } + } + // Check that platform service account is used with market place provisioning if strings.HasPrefix(c.Auth.GetClientID(), "DOSA_") { exception.Throw(ErrServiceAccount) @@ -1002,6 +1016,7 @@ func AddCentralConfigProperties(props properties.Properties, agentType AgentType props.AddStringProperty(pathAdditionalTags, "", "Additional Tags to Add to discovered APIs when publishing to Amplify Central") props.AddBoolProperty(pathAppendEnvironmentToTitle, true, "When true API titles and descriptions will be appended with environment name") props.AddIntProperty(pathProvisioningRetryCount, 0, "The number of retries, in case it fails, for any provisioning event", properties.WithUpperLimitInt(3)) + addProvisioningWebhookConfigProperties(props) AddMigrationConfigProperties(props) } } @@ -1039,6 +1054,7 @@ func ParseCentralConfig(props properties.Properties, agentType AgentType) (Centr TeamName: props.StringPropertyValue(pathTeam), AgentName: props.StringPropertyValue(pathAgentName), ProvisioningRetryCount: props.IntPropertyValue(pathProvisioningRetryCount), + ProvisioningWebhook: parseProvisioningWebhookConfig(props), InstanceValidatorEnabled: props.BoolPropertyValue(pathInstanceValidatorEnabled), Auth: &AuthConfiguration{ RegionSettings: regSet, diff --git a/pkg/config/provisioningwebhookconfig.go b/pkg/config/provisioningwebhookconfig.go new file mode 100644 index 000000000..2b3aec7e8 --- /dev/null +++ b/pkg/config/provisioningwebhookconfig.go @@ -0,0 +1,193 @@ +package config + +import ( + "fmt" + + "github.com/Axway/agent-sdk/pkg/cmd/properties" +) + +// ProvisioningWebhookAuthType - the authentication method used to call a provisioning webhook +type ProvisioningWebhookAuthType string + +const ( + // ProvisioningWebhookAuthNone - no authentication + ProvisioningWebhookAuthNone ProvisioningWebhookAuthType = "none" + // ProvisioningWebhookAuthBasic - HTTP Basic authentication (username/password) + ProvisioningWebhookAuthBasic ProvisioningWebhookAuthType = "basic" + // ProvisioningWebhookAuthAPIKey - a static API key sent as a configurable header + ProvisioningWebhookAuthAPIKey ProvisioningWebhookAuthType = "apiKey" + // ProvisioningWebhookAuthBearer - a static token sent as an Authorization: Bearer header + ProvisioningWebhookAuthBearer ProvisioningWebhookAuthType = "bearer" +) + +// ProvisioningWebhookConfig - Interface for the on-prem provisioning webhook config, one webhook per resource type +type ProvisioningWebhookConfig interface { + GetManagedApplicationWebhook() ProvisioningWebhookEndpointConfig + GetAccessRequestWebhook() ProvisioningWebhookEndpointConfig + GetCredentialWebhook() ProvisioningWebhookEndpointConfig + ValidateConfig() error +} + +// ProvisioningWebhookConfiguration - holds the provisioning webhook config for each resource type +type ProvisioningWebhookConfiguration struct { + ProvisioningWebhookConfig + ManagedApplication ProvisioningWebhookEndpointConfig `config:"managedApplication"` + AccessRequest ProvisioningWebhookEndpointConfig `config:"accessRequest"` + Credential ProvisioningWebhookEndpointConfig `config:"credential"` +} + +func newProvisioningWebhookConfig() ProvisioningWebhookConfig { + return &ProvisioningWebhookConfiguration{ + ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, + AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, + Credential: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential"}}, + } +} + +// GetManagedApplicationWebhook - Returns the webhook config used for ManagedApplication provisioning +func (c *ProvisioningWebhookConfiguration) GetManagedApplicationWebhook() ProvisioningWebhookEndpointConfig { + return c.ManagedApplication +} + +// GetAccessRequestWebhook - Returns the webhook config used for AccessRequest provisioning +func (c *ProvisioningWebhookConfiguration) GetAccessRequestWebhook() ProvisioningWebhookEndpointConfig { + return c.AccessRequest +} + +// GetCredentialWebhook - Returns the webhook config used for Credential provisioning +func (c *ProvisioningWebhookConfiguration) GetCredentialWebhook() ProvisioningWebhookEndpointConfig { + return c.Credential +} + +// ValidateConfig - Validates each configured webhook +func (c *ProvisioningWebhookConfiguration) ValidateConfig() error { + for _, webhook := range []ProvisioningWebhookEndpointConfig{c.ManagedApplication, c.AccessRequest, c.Credential} { + if err := webhook.ValidateConfig(); err != nil { + return err + } + } + return nil +} + +// ProvisioningWebhookEndpointConfig - Interface for a single provisioning webhook (one resource type). +// Extends WebhookConfig with a selectable auth method beyond a single secret. +type ProvisioningWebhookEndpointConfig interface { + WebhookConfig + GetAuthType() ProvisioningWebhookAuthType + GetUsername() string + GetPassword() string + GetAPIKeyHeader() string + GetAPIKeyValue() string +} + +// ProvisioningWebhookEndpointConfiguration - config for a single provisioning webhook, built on WebhookConfiguration +type ProvisioningWebhookEndpointConfiguration struct { + *WebhookConfiguration + AuthType string `config:"authType"` + Username string `config:"username"` + Password string `config:"password"` + APIKeyHeader string `config:"apiKeyHeader"` + APIKeyValue string `config:"apiKeyValue"` +} + +// GetAuthType - Returns the authentication method configured for this webhook +func (c *ProvisioningWebhookEndpointConfiguration) GetAuthType() ProvisioningWebhookAuthType { + return ProvisioningWebhookAuthType(c.AuthType) +} + +// GetUsername - Returns the username used for basic auth +func (c *ProvisioningWebhookEndpointConfiguration) GetUsername() string { + return c.Username +} + +// GetPassword - Returns the password used for basic auth +func (c *ProvisioningWebhookEndpointConfiguration) GetPassword() string { + return c.Password +} + +// GetAPIKeyHeader - Returns the header name the API key is sent on +func (c *ProvisioningWebhookEndpointConfiguration) GetAPIKeyHeader() string { + return c.APIKeyHeader +} + +// GetAPIKeyValue - Returns the API key value +func (c *ProvisioningWebhookEndpointConfiguration) GetAPIKeyValue() string { + return c.APIKeyValue +} + +// ValidateConfig - Validates the base webhook config (URL/headers), then the fields required by the configured auth type +func (c *ProvisioningWebhookEndpointConfiguration) ValidateConfig() error { + if err := c.WebhookConfiguration.ValidateConfig(); err != nil { + return err + } + if !c.IsConfigured() { + return nil + } + + switch c.GetAuthType() { + case ProvisioningWebhookAuthNone, "": + case ProvisioningWebhookAuthBasic: + if c.Username == "" || c.Password == "" { + return fmt.Errorf("central.%s.username and .password are required when authType is basic", c.Type) + } + case ProvisioningWebhookAuthAPIKey: + if c.APIKeyHeader == "" || c.APIKeyValue == "" { + return fmt.Errorf("central.%s.apiKeyHeader and .apiKeyValue are required when authType is apiKey", c.Type) + } + case ProvisioningWebhookAuthBearer: + if c.GetSecret() == "" { + return fmt.Errorf("central.%s.secret is required when authType is bearer", c.Type) + } + default: + return fmt.Errorf("central.%s.authType must be one of none, basic, apiKey, bearer", c.Type) + } + + return nil +} + +const ( + pathProvisioningWebhookManagedApplication = "central.provisioningWebhook.managedApplication" + pathProvisioningWebhookAccessRequest = "central.provisioningWebhook.accessRequest" + pathProvisioningWebhookCredential = "central.provisioningWebhook.credential" +) + +func addProvisioningWebhookConfigProperties(props properties.Properties) { + addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookManagedApplication, "ManagedApplication") + addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookAccessRequest, "AccessRequest") + addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookCredential, "Credential") +} + +func addSingleProvisioningWebhookProperties(props properties.Properties, path, resourceType string) { + props.AddStringProperty(path+".url", "", "URL of the webhook the agent calls instead of its own provisioning for "+resourceType+" events") + props.AddStringProperty(path+".headers", "", "Static headers to send with every "+resourceType+" provisioning webhook call") + props.AddStringProperty(path+".secret", "", "Bearer token for the "+resourceType+" provisioning webhook, used when authType is bearer") + props.AddStringProperty(path+".authType", string(ProvisioningWebhookAuthNone), "Authentication method for the "+resourceType+" provisioning webhook: none, basic, apiKey, bearer") + props.AddStringProperty(path+".username", "", "Username for "+resourceType+" provisioning webhook basic auth") + props.AddStringProperty(path+".password", "", "Password for "+resourceType+" provisioning webhook basic auth") + props.AddStringProperty(path+".apiKeyHeader", "", "Header name used to send the "+resourceType+" provisioning webhook API key") + props.AddStringProperty(path+".apiKeyValue", "", "API key value for the "+resourceType+" provisioning webhook") +} + +func parseProvisioningWebhookConfig(props properties.Properties) ProvisioningWebhookConfig { + return &ProvisioningWebhookConfiguration{ + ManagedApplication: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookManagedApplication, "provisioningWebhook.managedApplication"), + AccessRequest: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookAccessRequest, "provisioningWebhook.accessRequest"), + Credential: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookCredential, "provisioningWebhook.credential"), + } +} + +func parseSingleProvisioningWebhookConfig(props properties.Properties, path, name string) ProvisioningWebhookEndpointConfig { + return &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{ + Type: name, + URL: props.StringPropertyValue(path + ".url"), + Headers: props.StringPropertyValue(path + ".headers"), + Secret: props.StringPropertyValue(path + ".secret"), + }, + AuthType: props.StringPropertyValue(path + ".authType"), + Username: props.StringPropertyValue(path + ".username"), + Password: props.StringPropertyValue(path + ".password"), + APIKeyHeader: props.StringPropertyValue(path + ".apiKeyHeader"), + APIKeyValue: props.StringPropertyValue(path + ".apiKeyValue"), + } +} diff --git a/pkg/config/provisioningwebhookconfig_test.go b/pkg/config/provisioningwebhookconfig_test.go new file mode 100644 index 000000000..74974759f --- /dev/null +++ b/pkg/config/provisioningwebhookconfig_test.go @@ -0,0 +1,125 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProvisioningWebhookConfigNotConfigured(t *testing.T) { + cfg := newProvisioningWebhookConfig() + assert.False(t, cfg.GetManagedApplicationWebhook().IsConfigured()) + assert.False(t, cfg.GetAccessRequestWebhook().IsConfigured()) + assert.False(t, cfg.GetCredentialWebhook().IsConfigured()) + assert.Nil(t, cfg.ValidateConfig()) +} + +func TestProvisioningWebhookConfigAuthTypes(t *testing.T) { + tests := []struct { + name string + webhook *ProvisioningWebhookEndpointConfiguration + wantErr string + }{ + { + name: "none", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthNone), + }, + }, + { + name: "basic ok", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthBasic), + Username: "user", + Password: "pass", + }, + }, + { + name: "basic missing password", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthBasic), + Username: "user", + }, + wantErr: "central.provisioningWebhook.credential.username and .password are required when authType is basic", + }, + { + name: "apiKey ok", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthAPIKey), + APIKeyHeader: "X-Api-Key", + APIKeyValue: "abc123", + }, + }, + { + name: "apiKey missing header", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthAPIKey), + APIKeyValue: "abc123", + }, + wantErr: "central.provisioningWebhook.credential.apiKeyHeader and .apiKeyValue are required when authType is apiKey", + }, + { + name: "bearer ok", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar", Secret: "token"}, + AuthType: string(ProvisioningWebhookAuthBearer), + }, + }, + { + name: "bearer missing secret", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: string(ProvisioningWebhookAuthBearer), + }, + wantErr: "central.provisioningWebhook.credential.secret is required when authType is bearer", + }, + { + name: "invalid auth type", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar"}, + AuthType: "digest", + }, + wantErr: "central.provisioningWebhook.credential.authType must be one of none, basic, apiKey, bearer", + }, + { + name: "bad url", + webhook: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "xxxf"}, + }, + wantErr: "central.provisioningWebhook.credential.url is not a valid URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.webhook.ValidateConfig() + if tt.wantErr == "" { + assert.Nil(t, err) + } else { + assert.NotNil(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + } + }) + } +} + +func TestProvisioningWebhookConfigOnlyOneConfigured(t *testing.T) { + cfg := &ProvisioningWebhookConfiguration{ + ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, + AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, + Credential: &ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar", Secret: "token"}, + AuthType: string(ProvisioningWebhookAuthBearer), + }, + } + + assert.False(t, cfg.GetManagedApplicationWebhook().IsConfigured()) + assert.False(t, cfg.GetAccessRequestWebhook().IsConfigured()) + assert.True(t, cfg.GetCredentialWebhook().IsConfigured()) + assert.Nil(t, cfg.ValidateConfig()) +} diff --git a/pkg/config/webhookconfig.go b/pkg/config/webhookconfig.go index 9a72c1154..cdf8b4b0a 100644 --- a/pkg/config/webhookconfig.go +++ b/pkg/config/webhookconfig.go @@ -1,7 +1,7 @@ package config import ( - "errors" + "fmt" "net/url" "strings" @@ -20,15 +20,19 @@ type WebhookConfig interface { // WebhookConfiguration - do NOT make this an IConfigValidator, as it is validated as part of subscriptionConfig type WebhookConfiguration struct { WebhookConfig + // Type identifies which webhook config this is (e.g. "subscriptions.approvalWebhook", "provisioningWebhook"), + // used only to make ValidateConfig error messages point at the right config section. + Type string URL string `config:"url"` Headers string `config:"headers"` Secret string `config:"secret"` webhookHeaders map[string]string } -// NewWebhookConfig - -func NewWebhookConfig() WebhookConfig { - return &WebhookConfiguration{} +// NewWebhookConfig - Creates a webhook config identified by name (used to disambiguate error messages +// when more than one webhook config section exists, e.g. "subscriptions.approvalWebhook", "provisioningWebhook") +func NewWebhookConfig(name string) WebhookConfig { + return &WebhookConfiguration{Type: name} } // GetURL - Returns the URL @@ -57,7 +61,7 @@ func (c *WebhookConfiguration) ValidateConfig() error { if c.IsConfigured() { webhookURL := c.GetURL() if _, err := url.ParseRequestURI(webhookURL); err != nil { - return errors.New("central.subscriptions.approvalWebhook.URL is not a valid URL") + return fmt.Errorf("central.%s.url is not a valid URL", c.Type) } // headers are allowed to be empty, so only validate if there is a configured value @@ -69,13 +73,13 @@ func (c *WebhookConfiguration) ValidateConfig() error { for _, headerValue := range headersValues { hvArray := strings.Split(headerValue, ",Value=") if len(hvArray) != 2 { - return errors.New("could not parse value of central.subscriptions.approvalWebhook.headers") + return fmt.Errorf("could not parse value of central.%s.headers", c.Type) } hvArray[0] = strings.TrimPrefix(hvArray[0], "Header=") // handle the first header in the list c.webhookHeaders[hvArray[0]] = hvArray[1] } } - log.Trace("Subscription approval webhook configuration set") + log.Tracef("%s webhook configuration set", c.Type) } return nil diff --git a/pkg/config/webhookconfig_test.go b/pkg/config/webhookconfig_test.go index d9b55f33e..d9f06526a 100644 --- a/pkg/config/webhookconfig_test.go +++ b/pkg/config/webhookconfig_test.go @@ -7,7 +7,7 @@ import ( ) func TestWebookConfig(t *testing.T) { - cfg := NewWebhookConfig() + cfg := NewWebhookConfig("subscriptions.approvalWebhook") assert.False(t, cfg.IsConfigured()) err := cfg.ValidateConfig() @@ -15,6 +15,7 @@ func TestWebookConfig(t *testing.T) { // this one should be all good cfg = &WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "https://foo.bar:4567", Headers: "Header=contentType,Value=application/json", Secret: "1234", @@ -31,6 +32,7 @@ func TestWebookConfig(t *testing.T) { // this one should be all good with no headers cfg = &WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "https://foo.bar:4567", Headers: "", Secret: "1234", @@ -41,6 +43,7 @@ func TestWebookConfig(t *testing.T) { // this one should be all good with no secret cfg = &WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "https://foo.bar:4567", Headers: "Header=contentType,Value=application/json", Secret: "", @@ -51,16 +54,18 @@ func TestWebookConfig(t *testing.T) { // this one should be bad url cfg = &WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "xxxf", Headers: "Header=contentType,Value=application/json", Secret: "1234", } err = cfg.ValidateConfig() assert.NotNil(t, err) - assert.Equal(t, "central.subscriptions.approvalWebhook.URL is not a valid URL", err.Error()) + assert.Equal(t, "central.subscriptions.approvalWebhook.url is not a valid URL", err.Error()) // this one should be bad header cfg = &WebhookConfiguration{ + Type: "subscriptions.approvalWebhook", URL: "https://foo.bar:4567", Headers: "Header=contentType,Vue=application/json", Secret: "1234", From 43856d99e67056a65d1686f852c31f0251885654 Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Thu, 10 Sep 2026 14:15:35 +0300 Subject: [PATCH 02/12] APIGOV-33010 improvements --- pkg/agent/handler/accessrequest.go | 24 +++++-- pkg/agent/handler/credential.go | 28 ++++++-- pkg/agent/handler/managedapplication.go | 28 +++++--- pkg/agent/provisioningwebhook/dispatch.go | 32 ++++++++- .../provisioningwebhook/dispatch_test.go | 72 +++++++++++++++++++ pkg/config/provisioningwebhookconfig.go | 16 +++++ 6 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 pkg/agent/provisioningwebhook/dispatch_test.go diff --git a/pkg/agent/handler/accessrequest.go b/pkg/agent/handler/accessrequest.go index 585012e24..79d4cc2f2 100644 --- a/pkg/agent/handler/accessrequest.go +++ b/pkg/agent/handler/accessrequest.go @@ -65,6 +65,7 @@ func NewAccessRequestHandler(prov prov.AccessProvisioner, cache agentcache.Manag client: client, encryptSchema: encryptSchema, customUnitHandler: customUnitHandler, + webhookCfg: config.NewProvisioningWebhookEndpointConfig("provisioningWebhook.accessRequest"), } for _, o := range opts { o(arh) @@ -78,7 +79,7 @@ func (h *accessRequestHandler) ShouldHandle(ctx context.Context, event *proto.Ev return true } if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { - return true + return h.webhookCfg.IsConfigured() } if h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false @@ -200,7 +201,7 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) *management.AccessRequest { log := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationProvision) { + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationProvision) { return ar } @@ -234,8 +235,12 @@ func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.Acc updateDataFromEnumMap(ar.Spec.Data, ard.Spec.Schema) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationProvision, *req)) + if h.webhookCfg.IsConfigured() { + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationProvision, *req)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, ar, err) + return ar + } markWebhookDispatched(ar, webhookOperationProvision) return ar } @@ -331,7 +336,7 @@ func (h *accessRequestHandler) onError(_ context.Context, ar *management.AccessR func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.AccessRequest) { log := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationDeprovision) { + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationDeprovision) { return } @@ -352,8 +357,13 @@ func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.Ac return } - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationDeprovision, *req)) + if h.webhookCfg.IsConfigured() { + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationDeprovision, *req)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, ar, err) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + return + } markWebhookDispatched(ar, webhookOperationDeprovision) h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) return diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index a8799ea46..26aad844e 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -70,6 +70,7 @@ func NewCredentialHandler(prov credProv, client client, providerRegistry oauth.I client: client, encryptSchema: encryptSchema, idpProviderRegistry: providerRegistry, + webhookCfg: config.NewProvisioningWebhookEndpointConfig("provisioningWebhook.credential"), } for _, o := range opts { @@ -81,7 +82,7 @@ func NewCredentialHandler(prov credProv, client client, providerRegistry oauth.I func (h *credentials) ShouldHandle(ctx context.Context, event *proto.Event) bool { action := GetActionFromContext(ctx) if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { - return true + return h.webhookCfg.IsConfigured() } if action == proto.Event_DELETED || h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false @@ -225,7 +226,7 @@ func (h *credentials) shouldProcessUpdating(cr *management.Credential) []prov.Cr func (h *credentials) onDeleting(ctx context.Context, cred *management.Credential) { logger := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationDeprovision) { + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationDeprovision) { return } @@ -249,8 +250,13 @@ func (h *credentials) onDeleting(ctx context.Context, cred *management.Credentia return } - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationDeprovision, provCreds)) + if h.webhookCfg.IsConfigured() { + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationDeprovision, provCreds)); err != nil { + logger.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, cred, err) + h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + return + } markWebhookDispatched(cred, webhookOperationDeprovision) h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) return @@ -310,7 +316,7 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential // check the application status logger := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationProvision) { + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationProvision) { return cred } @@ -332,8 +338,16 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential return cred } - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationProvision, provCreds)) + if h.webhookCfg.IsConfigured() { + // normal (non-webhook) provisioning always sets this so agents-controller can schedule credential + // expiry off the status subresource; set it here too, before dispatch, so it's already present in + // x-agent-details by the time mirrorWebhookDetails runs. + util.SetAgentDetailsKey(cred, prov.HandleCredentialExpiry, "true") + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationProvision, provCreds)); err != nil { + logger.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, cred, err) + return cred + } markWebhookDispatched(cred, webhookOperationProvision) return cred } diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index a6a170d72..a4805e13e 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -62,9 +62,10 @@ func WithManagedAppProvisioningWebhook(cfg config.ProvisioningWebhookEndpointCon func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentcache.Manager, client client, opts ...func(c *managedApplication)) Handler { ma := &managedApplication{ - prov: prov, - cache: cache, - client: client, + prov: prov, + cache: cache, + client: client, + webhookCfg: config.NewProvisioningWebhookEndpointConfig("provisioningWebhook.managedApplication"), } if tc, ok := client.(teamFetcher); ok { ma.teamClient = tc @@ -78,7 +79,7 @@ func NewManagedApplicationHandler(prov prov.ApplicationProvisioner, cache agentc func (h *managedApplication) ShouldHandle(ctx context.Context, event *proto.Event) bool { action := GetActionFromContext(ctx) if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { - return true + return h.webhookCfg.IsConfigured() } if h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false @@ -140,11 +141,15 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, func (h *managedApplication) onPending(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) error { log := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { + if h.webhookCfg.IsConfigured() { if webhookDispatchedFor(app, webhookOperationProvision) { return nil } - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationProvision, pma)) + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationProvision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(app, err) + return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + } markWebhookDispatched(app, webhookOperationProvision) return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) } @@ -207,7 +212,7 @@ func (h *managedApplication) provision(pma provManagedApp) prov.RequestStatus { func (h *managedApplication) onDeleting(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) { log := getLoggerFromContext(ctx) - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() && webhookDispatchedFor(app, webhookOperationDeprovision) { + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(app, webhookOperationDeprovision) { return } @@ -218,8 +223,13 @@ func (h *managedApplication) onDeleting(ctx context.Context, app *management.Man return } - if h.webhookCfg != nil && h.webhookCfg.IsConfigured() { - provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationDeprovision, pma)) + if h.webhookCfg.IsConfigured() { + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationDeprovision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(app, err) + h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + return + } markWebhookDispatched(app, webhookOperationDeprovision) h.client.CreateSubResource(app.ResourceMeta, app.SubResources) return diff --git a/pkg/agent/provisioningwebhook/dispatch.go b/pkg/agent/provisioningwebhook/dispatch.go index 4dceb376e..10c00ec19 100644 --- a/pkg/agent/provisioningwebhook/dispatch.go +++ b/pkg/agent/provisioningwebhook/dispatch.go @@ -4,18 +4,24 @@ import ( "encoding/base64" "encoding/json" "fmt" + "time" "github.com/Axway/agent-sdk/pkg/api" "github.com/Axway/agent-sdk/pkg/config" + "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/util/log" ) var logger = log.NewFieldLogger().WithComponent("provisioningWebhook").WithPackage("agent") +const baseRetryTimeout = 15 * time.Second + // Dispatch sends payload as the JSON body of a POST request to the webhook configured in cfg, applying // whichever auth method is configured, using client to make the call. The agent does not wait for the // client's webhook to actually finish processing - this only reports (via the returned error and logging) -// whether the HTTP call itself was accepted, not whether provisioning/deprovisioning completed. +// whether the HTTP call itself was accepted, not whether provisioning/deprovisioning completed. On failure +// (network error or non-2xx), retries up to cfg.GetRetryCount() additional times with doubling backoff +// before giving up. func Dispatch(client api.Client, cfg config.ProvisioningWebhookEndpointConfig, payload interface{}) error { body, err := json.Marshal(payload) if err != nil { @@ -29,9 +35,30 @@ func Dispatch(client api.Client, cfg config.ProvisioningWebhookEndpointConfig, p } applyAuth(cfg, headers) + timeout := baseRetryTimeout + for attempt := 0; ; attempt++ { + err = send(client, cfg.GetURL(), headers, body) + if err == nil { + logger.Trace("dispatched to provisioning webhook") + return nil + } + + if attempt >= cfg.GetRetryCount() { + return err + } + + logger.WithError(err).Warnf("provisioning webhook call failed, retrying (attempt %d of %d)", attempt+1, cfg.GetRetryCount()) + if util.IsNotTest() { + time.Sleep(timeout) + } + timeout = timeout * 2 + } +} + +func send(client api.Client, url string, headers map[string]string, body []byte) error { resp, err := client.Send(api.Request{ Method: api.POST, - URL: cfg.GetURL(), + URL: url, Headers: headers, Body: body, }) @@ -46,7 +73,6 @@ func Dispatch(client api.Client, cfg config.ProvisioningWebhookEndpointConfig, p return err } - logger.Trace("dispatched to provisioning webhook") return nil } diff --git a/pkg/agent/provisioningwebhook/dispatch_test.go b/pkg/agent/provisioningwebhook/dispatch_test.go new file mode 100644 index 000000000..5e9c839d6 --- /dev/null +++ b/pkg/agent/provisioningwebhook/dispatch_test.go @@ -0,0 +1,72 @@ +package provisioningwebhook + +import ( + "errors" + "testing" + + "github.com/Axway/agent-sdk/pkg/api" + "github.com/Axway/agent-sdk/pkg/config" + "github.com/stretchr/testify/assert" +) + +type fakeClient struct { + responses []fakeResponse + calls int +} + +type fakeResponse struct { + code int + err error +} + +func (f *fakeClient) Send(_ api.Request) (*api.Response, error) { + r := f.responses[f.calls] + f.calls++ + if r.err != nil { + return nil, r.err + } + return &api.Response{Code: r.code}, nil +} + +func newCfg(retryCount int) config.ProvisioningWebhookEndpointConfig { + return &config.ProvisioningWebhookEndpointConfiguration{ + WebhookConfiguration: &config.WebhookConfiguration{URL: "http://webhook.example.com"}, + RetryCount: retryCount, + } +} + +func TestDispatch_SucceedsWithoutRetry(t *testing.T) { + client := &fakeClient{responses: []fakeResponse{{code: 200}}} + err := Dispatch(client, newCfg(2), map[string]string{"a": "b"}) + assert.NoError(t, err) + assert.Equal(t, 1, client.calls) +} + +func TestDispatch_RetriesThenSucceeds(t *testing.T) { + client := &fakeClient{responses: []fakeResponse{ + {err: errors.New("connection refused")}, + {code: 500}, + {code: 200}, + }} + err := Dispatch(client, newCfg(2), map[string]string{"a": "b"}) + assert.NoError(t, err) + assert.Equal(t, 3, client.calls) +} + +func TestDispatch_ExhaustsRetriesAndFails(t *testing.T) { + client := &fakeClient{responses: []fakeResponse{ + {code: 500}, + {code: 500}, + {code: 500}, + }} + err := Dispatch(client, newCfg(2), map[string]string{"a": "b"}) + assert.Error(t, err) + assert.Equal(t, 3, client.calls) // initial attempt + 2 retries +} + +func TestDispatch_NoRetryConfiguredFailsImmediately(t *testing.T) { + client := &fakeClient{responses: []fakeResponse{{code: 500}}} + err := Dispatch(client, newCfg(0), map[string]string{"a": "b"}) + assert.Error(t, err) + assert.Equal(t, 1, client.calls) +} diff --git a/pkg/config/provisioningwebhookconfig.go b/pkg/config/provisioningwebhookconfig.go index 2b3aec7e8..cedd41b9e 100644 --- a/pkg/config/provisioningwebhookconfig.go +++ b/pkg/config/provisioningwebhookconfig.go @@ -78,6 +78,14 @@ type ProvisioningWebhookEndpointConfig interface { GetPassword() string GetAPIKeyHeader() string GetAPIKeyValue() string + GetRetryCount() int +} + +// NewProvisioningWebhookEndpointConfig - Creates an unconfigured provisioning webhook endpoint config +// (IsConfigured() returns false) identified by name, safe to use as a handler default so callers never +// need to nil-check before calling IsConfigured()/ValidateConfig(). +func NewProvisioningWebhookEndpointConfig(name string) ProvisioningWebhookEndpointConfig { + return &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: name}} } // ProvisioningWebhookEndpointConfiguration - config for a single provisioning webhook, built on WebhookConfiguration @@ -88,6 +96,7 @@ type ProvisioningWebhookEndpointConfiguration struct { Password string `config:"password"` APIKeyHeader string `config:"apiKeyHeader"` APIKeyValue string `config:"apiKeyValue"` + RetryCount int `config:"retryCount"` } // GetAuthType - Returns the authentication method configured for this webhook @@ -115,6 +124,11 @@ func (c *ProvisioningWebhookEndpointConfiguration) GetAPIKeyValue() string { return c.APIKeyValue } +// GetRetryCount - Returns the number of additional attempts Dispatch should make if the webhook call fails +func (c *ProvisioningWebhookEndpointConfiguration) GetRetryCount() int { + return c.RetryCount +} + // ValidateConfig - Validates the base webhook config (URL/headers), then the fields required by the configured auth type func (c *ProvisioningWebhookEndpointConfiguration) ValidateConfig() error { if err := c.WebhookConfiguration.ValidateConfig(); err != nil { @@ -166,6 +180,7 @@ func addSingleProvisioningWebhookProperties(props properties.Properties, path, r props.AddStringProperty(path+".password", "", "Password for "+resourceType+" provisioning webhook basic auth") props.AddStringProperty(path+".apiKeyHeader", "", "Header name used to send the "+resourceType+" provisioning webhook API key") props.AddStringProperty(path+".apiKeyValue", "", "API key value for the "+resourceType+" provisioning webhook") + props.AddIntProperty(path+".retryCount", 0, "Number of additional attempts to make if the "+resourceType+" provisioning webhook call fails") } func parseProvisioningWebhookConfig(props properties.Properties) ProvisioningWebhookConfig { @@ -189,5 +204,6 @@ func parseSingleProvisioningWebhookConfig(props properties.Properties, path, nam Password: props.StringPropertyValue(path + ".password"), APIKeyHeader: props.StringPropertyValue(path + ".apiKeyHeader"), APIKeyValue: props.StringPropertyValue(path + ".apiKeyValue"), + RetryCount: props.IntPropertyValue(path + ".retryCount"), } } From e08845fe7d427d37fb49784a6c0494b868a52f19 Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Thu, 10 Sep 2026 15:44:59 +0300 Subject: [PATCH 03/12] APIGOV-33010 managed app profile webhook --- .../handler/managedapplicationprofile.go | 78 +++++++++++++++---- pkg/agent/handler/webhook.go | 25 ++++++ pkg/agent/provisioning.go | 3 +- pkg/config/provisioningwebhookconfig.go | 37 +++++---- pkg/config/provisioningwebhookconfig_test.go | 7 +- 5 files changed, 117 insertions(+), 33 deletions(-) diff --git a/pkg/agent/handler/managedapplicationprofile.go b/pkg/agent/handler/managedapplicationprofile.go index b6709569d..f456854e4 100644 --- a/pkg/agent/handler/managedapplicationprofile.go +++ b/pkg/agent/handler/managedapplicationprofile.go @@ -3,11 +3,14 @@ package handler import ( "context" + "github.com/Axway/agent-sdk/pkg/agent/provisioningwebhook" + "github.com/Axway/agent-sdk/pkg/api" apiv1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" management "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/management/v1" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" prov "github.com/Axway/agent-sdk/pkg/apic/provisioning" + "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util" "github.com/Axway/agent-sdk/pkg/util/log" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" @@ -24,24 +27,43 @@ type managedApplicationProfileCache interface { type managedApplicationProfile struct { marketplaceHandler - logger log.FieldLogger - prov prov.ApplicationProfileProvisioner - cache managedApplicationProfileCache - client client + logger log.FieldLogger + prov prov.ApplicationProfileProvisioner + cache managedApplicationProfileCache + client client + webhookCfg config.ProvisioningWebhookEndpointConfig + webhookClient api.Client +} + +// WithManagedApplicationProfileProvisioningWebhook configures the webhook the handler calls instead of +// its own registered Provisioning implementation, when cfg.IsConfigured() +func WithManagedApplicationProfileProvisioningWebhook(cfg config.ProvisioningWebhookEndpointConfig, client api.Client) func(c *managedApplicationProfile) { + return func(c *managedApplicationProfile) { + c.webhookCfg = cfg + c.webhookClient = client + } } // NewManagedApplicationProfileHandler creates a Handler for Credentials -func NewManagedApplicationProfileHandler(prov prov.ApplicationProfileProvisioner, cache managedApplicationProfileCache, client client) Handler { - return &managedApplicationProfile{ - logger: log.NewFieldLogger().WithComponent("managedApplicationProfile").WithPackage("agent.handler"), - prov: prov, - cache: cache, - client: client, +func NewManagedApplicationProfileHandler(prov prov.ApplicationProfileProvisioner, cache managedApplicationProfileCache, client client, opts ...func(c *managedApplicationProfile)) Handler { + p := &managedApplicationProfile{ + logger: log.NewFieldLogger().WithComponent("managedApplicationProfile").WithPackage("agent.handler"), + prov: prov, + cache: cache, + client: client, + webhookCfg: config.NewProvisioningWebhookEndpointConfig("provisioningWebhook.managedApplicationProfile"), + } + for _, o := range opts { + o(p) } + return p } func (h *managedApplicationProfile) ShouldHandle(ctx context.Context, event *proto.Event) bool { action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && event.Metadata.GetSubresource() == defs.XWebhookDetails { + return h.webhookCfg.IsConfigured() + } if h.prov == nil || h.shouldIgnore(action, event.Metadata) { return false } @@ -62,6 +84,14 @@ func (h *managedApplicationProfile) Handle(ctx context.Context, meta *proto.Even return nil } + action := GetActionFromContext(ctx) + if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { + if mirrorWebhookDetails(profile) { + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + } + return nil + } + if ok := isStatusFound(profile.Status); !ok { log.Debug("could not handle application request as it did not have a status subresource") return nil @@ -78,18 +108,15 @@ func (h *managedApplicationProfile) Handle(ctx context.Context, meta *proto.Even func (h *managedApplicationProfile) onPending(ctx context.Context, profile *management.ManagedApplicationProfile) error { log := getLoggerFromContext(ctx) - defer func() { - statusErr := h.client.CreateSubResource(profile.ResourceMeta, map[string]interface{}{"status": profile.Status}) - if statusErr != nil { - log.WithError(statusErr).Error("error creating status subresources") - } - }() + if h.webhookCfg.IsConfigured() && webhookDispatchedFor(profile, webhookOperationProvision) { + return nil + } app, err := h.getManagedApp(ctx, profile) if err != nil { log.WithError(err).Error("error getting managed app") h.onError(ctx, profile, err) - return err + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) } h.checkForEnumValueMap(ctx, profile.Spec.Data, profile.Spec.ApplicationProfileDefinition) @@ -104,6 +131,23 @@ func (h *managedApplicationProfile) onPending(ctx context.Context, profile *mana id: app.Metadata.ID, } + if h.webhookCfg.IsConfigured() { + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationProfileRequest(webhookOperationProvision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, profile, err) + return h.client.CreateSubResource(profile.ResourceMeta, map[string]interface{}{"status": profile.Status}) + } + markWebhookDispatched(profile, webhookOperationProvision) + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + } + + defer func() { + statusErr := h.client.CreateSubResource(profile.ResourceMeta, map[string]interface{}{"status": profile.Status}) + if statusErr != nil { + log.WithError(statusErr).Error("error creating status subresources") + } + }() + status := h.prov.ApplicationProfileRequestProvision(pma) profile.Status = prov.NewStatusReason(status) diff --git a/pkg/agent/handler/webhook.go b/pkg/agent/handler/webhook.go index a64c6a4d1..eee1ad761 100644 --- a/pkg/agent/handler/webhook.go +++ b/pkg/agent/handler/webhook.go @@ -83,6 +83,31 @@ func newWebhookApplicationRequest(operation string, a provManagedApp) webhookApp } } +// webhookApplicationProfileRequest is the payload sent to the configured provisioning webhook for a ManagedApplicationProfile event +type webhookApplicationProfileRequest struct { + Operation string `json:"operation"` + ID string `json:"id"` + ManagedApplicationName string `json:"managedApplicationName"` + ApplicationProfileDefinition string `json:"applicationProfileDefinition"` + TeamName string `json:"teamName"` + ConsumerOrgID string `json:"consumerOrgId,omitempty"` + Attributes map[string]interface{} `json:"attributes,omitempty"` + ApplicationDetails map[string]interface{} `json:"applicationDetails,omitempty"` +} + +func newWebhookApplicationProfileRequest(operation string, p provManagedAppProfile) webhookApplicationProfileRequest { + return webhookApplicationProfileRequest{ + Operation: operation, + ID: p.id, + ManagedApplicationName: p.managedAppName, + ApplicationProfileDefinition: p.profileDefinition, + TeamName: p.teamName, + ConsumerOrgID: p.consumerOrgID, + Attributes: p.attributes, + ApplicationDetails: p.data, + } +} + // webhookQuota mirrors the fields of prov.Quota needed by the webhook contract type webhookQuota struct { Limit int64 `json:"limit"` diff --git a/pkg/agent/provisioning.go b/pkg/agent/provisioning.go index 31f6c2f2c..634f4d95e 100644 --- a/pkg/agent/provisioning.go +++ b/pkg/agent/provisioning.go @@ -627,7 +627,8 @@ func registerApplicationProfileProvisioner(provisioner interface{}) { if appProfileProv, ok := provisioner.(provisioning.ApplicationProfileProvisioner); ok { agent.proxyResourceHandler.RegisterTargetHandler( management.ApplicationProfileDefinitionGVK().Kind, - handler.NewManagedApplicationProfileHandler(appProfileProv, agent.cacheManager, agent.apicClient), + handler.NewManagedApplicationProfileHandler(appProfileProv, agent.cacheManager, agent.apicClient, + handler.WithManagedApplicationProfileProvisioningWebhook(agent.cfg.GetProvisioningWebhookConfig().GetManagedApplicationProfileWebhook(), provisioningWebhookClient())), ) } } diff --git a/pkg/config/provisioningwebhookconfig.go b/pkg/config/provisioningwebhookconfig.go index cedd41b9e..72d6af6e1 100644 --- a/pkg/config/provisioningwebhookconfig.go +++ b/pkg/config/provisioningwebhookconfig.go @@ -23,6 +23,7 @@ const ( // ProvisioningWebhookConfig - Interface for the on-prem provisioning webhook config, one webhook per resource type type ProvisioningWebhookConfig interface { GetManagedApplicationWebhook() ProvisioningWebhookEndpointConfig + GetManagedApplicationProfileWebhook() ProvisioningWebhookEndpointConfig GetAccessRequestWebhook() ProvisioningWebhookEndpointConfig GetCredentialWebhook() ProvisioningWebhookEndpointConfig ValidateConfig() error @@ -31,16 +32,18 @@ type ProvisioningWebhookConfig interface { // ProvisioningWebhookConfiguration - holds the provisioning webhook config for each resource type type ProvisioningWebhookConfiguration struct { ProvisioningWebhookConfig - ManagedApplication ProvisioningWebhookEndpointConfig `config:"managedApplication"` - AccessRequest ProvisioningWebhookEndpointConfig `config:"accessRequest"` - Credential ProvisioningWebhookEndpointConfig `config:"credential"` + ManagedApplication ProvisioningWebhookEndpointConfig `config:"managedApplication"` + ManagedApplicationProfile ProvisioningWebhookEndpointConfig `config:"managedApplicationProfile"` + AccessRequest ProvisioningWebhookEndpointConfig `config:"accessRequest"` + Credential ProvisioningWebhookEndpointConfig `config:"credential"` } func newProvisioningWebhookConfig() ProvisioningWebhookConfig { return &ProvisioningWebhookConfiguration{ - ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, - AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, - Credential: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential"}}, + ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, + ManagedApplicationProfile: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplicationProfile"}}, + AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, + Credential: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential"}}, } } @@ -49,6 +52,11 @@ func (c *ProvisioningWebhookConfiguration) GetManagedApplicationWebhook() Provis return c.ManagedApplication } +// GetManagedApplicationProfileWebhook - Returns the webhook config used for ManagedApplicationProfile provisioning +func (c *ProvisioningWebhookConfiguration) GetManagedApplicationProfileWebhook() ProvisioningWebhookEndpointConfig { + return c.ManagedApplicationProfile +} + // GetAccessRequestWebhook - Returns the webhook config used for AccessRequest provisioning func (c *ProvisioningWebhookConfiguration) GetAccessRequestWebhook() ProvisioningWebhookEndpointConfig { return c.AccessRequest @@ -61,7 +69,7 @@ func (c *ProvisioningWebhookConfiguration) GetCredentialWebhook() ProvisioningWe // ValidateConfig - Validates each configured webhook func (c *ProvisioningWebhookConfiguration) ValidateConfig() error { - for _, webhook := range []ProvisioningWebhookEndpointConfig{c.ManagedApplication, c.AccessRequest, c.Credential} { + for _, webhook := range []ProvisioningWebhookEndpointConfig{c.ManagedApplication, c.ManagedApplicationProfile, c.AccessRequest, c.Credential} { if err := webhook.ValidateConfig(); err != nil { return err } @@ -160,13 +168,15 @@ func (c *ProvisioningWebhookEndpointConfiguration) ValidateConfig() error { } const ( - pathProvisioningWebhookManagedApplication = "central.provisioningWebhook.managedApplication" - pathProvisioningWebhookAccessRequest = "central.provisioningWebhook.accessRequest" - pathProvisioningWebhookCredential = "central.provisioningWebhook.credential" + pathProvisioningWebhookManagedApplication = "central.provisioningWebhook.managedApplication" + pathProvisioningWebhookManagedApplicationProfile = "central.provisioningWebhook.managedApplicationProfile" + pathProvisioningWebhookAccessRequest = "central.provisioningWebhook.accessRequest" + pathProvisioningWebhookCredential = "central.provisioningWebhook.credential" ) func addProvisioningWebhookConfigProperties(props properties.Properties) { addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookManagedApplication, "ManagedApplication") + addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookManagedApplicationProfile, "ManagedApplicationProfile") addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookAccessRequest, "AccessRequest") addSingleProvisioningWebhookProperties(props, pathProvisioningWebhookCredential, "Credential") } @@ -185,9 +195,10 @@ func addSingleProvisioningWebhookProperties(props properties.Properties, path, r func parseProvisioningWebhookConfig(props properties.Properties) ProvisioningWebhookConfig { return &ProvisioningWebhookConfiguration{ - ManagedApplication: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookManagedApplication, "provisioningWebhook.managedApplication"), - AccessRequest: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookAccessRequest, "provisioningWebhook.accessRequest"), - Credential: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookCredential, "provisioningWebhook.credential"), + ManagedApplication: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookManagedApplication, "provisioningWebhook.managedApplication"), + ManagedApplicationProfile: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookManagedApplicationProfile, "provisioningWebhook.managedApplicationProfile"), + AccessRequest: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookAccessRequest, "provisioningWebhook.accessRequest"), + Credential: parseSingleProvisioningWebhookConfig(props, pathProvisioningWebhookCredential, "provisioningWebhook.credential"), } } diff --git a/pkg/config/provisioningwebhookconfig_test.go b/pkg/config/provisioningwebhookconfig_test.go index 74974759f..80919d80c 100644 --- a/pkg/config/provisioningwebhookconfig_test.go +++ b/pkg/config/provisioningwebhookconfig_test.go @@ -9,6 +9,7 @@ import ( func TestProvisioningWebhookConfigNotConfigured(t *testing.T) { cfg := newProvisioningWebhookConfig() assert.False(t, cfg.GetManagedApplicationWebhook().IsConfigured()) + assert.False(t, cfg.GetManagedApplicationProfileWebhook().IsConfigured()) assert.False(t, cfg.GetAccessRequestWebhook().IsConfigured()) assert.False(t, cfg.GetCredentialWebhook().IsConfigured()) assert.Nil(t, cfg.ValidateConfig()) @@ -110,8 +111,9 @@ func TestProvisioningWebhookConfigAuthTypes(t *testing.T) { func TestProvisioningWebhookConfigOnlyOneConfigured(t *testing.T) { cfg := &ProvisioningWebhookConfiguration{ - ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, - AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, + ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, + ManagedApplicationProfile: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplicationProfile"}}, + AccessRequest: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.accessRequest"}}, Credential: &ProvisioningWebhookEndpointConfiguration{ WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.credential", URL: "https://foo.bar", Secret: "token"}, AuthType: string(ProvisioningWebhookAuthBearer), @@ -119,6 +121,7 @@ func TestProvisioningWebhookConfigOnlyOneConfigured(t *testing.T) { } assert.False(t, cfg.GetManagedApplicationWebhook().IsConfigured()) + assert.False(t, cfg.GetManagedApplicationProfileWebhook().IsConfigured()) assert.False(t, cfg.GetAccessRequestWebhook().IsConfigured()) assert.True(t, cfg.GetCredentialWebhook().IsConfigured()) assert.Nil(t, cfg.ValidateConfig()) From 3db17d53a708ab589187a8341d7aaee0a710662a Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Fri, 11 Sep 2026 14:50:54 +0300 Subject: [PATCH 04/12] APIGOV-33010 managed app improvements --- pkg/agent/handler/managedapplication.go | 135 ++++++++++++++++++------ pkg/agent/handler/marketplacehandler.go | 15 +++ pkg/agent/handler/webhook.go | 32 +++++- 3 files changed, 150 insertions(+), 32 deletions(-) diff --git a/pkg/agent/handler/managedapplication.go b/pkg/agent/handler/managedapplication.go index a4805e13e..9bf83d3a4 100644 --- a/pkg/agent/handler/managedapplication.go +++ b/pkg/agent/handler/managedapplication.go @@ -16,6 +16,7 @@ import ( "github.com/Axway/agent-sdk/pkg/authz/oauth" "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/util" + "github.com/Axway/agent-sdk/pkg/util/log" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" ) @@ -102,8 +103,11 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, action := GetActionFromContext(ctx) if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { - if mirrorWebhookDetails(app) { - return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + if webhookDispatchedFor(app, webhookOperationProvision) { + return h.postWebhookProvisionProcess(app) + } + if webhookDispatchedFor(app, webhookOperationDeprovision) { + h.postWebhookDeprovisionProcess(log, app) } return nil } @@ -125,11 +129,22 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, id: app.Metadata.ID, } + if ok := h.shouldProvisionWebhook(app.Status, app.Metadata.State, h.webhookCfg); ok { + log.Trace("processing resource in pending status via provisioning webhook") + return h.onWebhookProvision(log, app, ma) + } + if ok := h.shouldProcessPending(app.Status, app.Metadata.State); ok { log.Trace("processing resource in pending status") return h.onPending(ctx, app, ma) } + if ok := h.shouldDeprovisionWebhook(app.Status, app.Metadata.State, app.Finalizers, h.webhookCfg); ok { + log.Trace("processing resource in deleting state via deprovisioning webhook") + h.onWebhookDeprovision(ctx, log, app, ma) + return nil + } + if ok := h.shouldProcessDeleting(app.Status, app.Metadata.State, app.Finalizers); ok { log.Trace("processing resource in deleting state") h.onDeleting(ctx, app, ma) @@ -138,21 +153,46 @@ func (h *managedApplication) Handle(ctx context.Context, meta *proto.EventMeta, return nil } -func (h *managedApplication) onPending(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) error { - log := getLoggerFromContext(ctx) - - if h.webhookCfg.IsConfigured() { - if webhookDispatchedFor(app, webhookOperationProvision) { - return nil - } - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationProvision, pma)); err != nil { - log.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(app, err) - return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) - } - markWebhookDispatched(app, webhookOperationProvision) +// postWebhookProvisionProcess mirrors the webhook's x-webhook-details into x-agent-details, and adds the +// finalizer once status reflects success - status is the webhook's own responsibility (see +// docs/discovery/provisioning-webhook.md), so app.Status already reflects the outcome by the time this +// runs. Only called when the dispatched operation was a provision - see Handle. +func (h *managedApplication) postWebhookProvisionProcess(app *management.ManagedApplication) error { + if app.Status.Level == prov.Success.String() && !hasFinalizer(app.Finalizers, maFinalizer) { + // only add finalizer on success + ri, _ := app.AsInstance() + h.client.UpdateResourceFinalizer(ri, maFinalizer, "", true) + } + if mirrorWebhookDetails(app) { return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) } + return nil +} + +// postWebhookDeprovisionProcess removes the finalizer (if still present) and drops the application from +// cache once the webhook reports it successfully completed a deprovision. app.Status can't be used for +// this - it's whatever it already was when shouldDeprovisionWebhook let the event through, not a signal +// from the webhook - so success/failure is read from the reserved "status" (and, on failure, "message") +// keys the webhook writes inside x-webhook-details itself. Status itself is the webhook's own +// responsibility (see docs/discovery/provisioning-webhook.md), so on failure this only logs - it doesn't +// write anything. No mirroring here - deprovision typically has no data to report. Only called when the +// dispatched operation was a deprovision - see Handle. +func (h *managedApplication) postWebhookDeprovisionProcess(log log.FieldLogger, app *management.ManagedApplication) { + if webhookDetailsValue(app, webhookStatusKey) != webhookStatusSuccess { + message := webhookDetailsValue(app, webhookMessageKey) + log.WithField("message", message).Error("provisioning webhook reported deprovision failure") + return + } + + if hasFinalizer(app.Finalizers, maFinalizer) { + ri, _ := app.AsInstance() + h.client.UpdateResourceFinalizer(ri, maFinalizer, "", false) + } + h.cache.DeleteManagedApplication(app.Metadata.ID) +} + +func (h *managedApplication) onPending(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) error { + log := getLoggerFromContext(ctx) status := h.provision(pma) app.Status = prov.NewStatusReason(status) @@ -186,6 +226,21 @@ func (h *managedApplication) onPending(ctx context.Context, app *management.Mana return err } +// onWebhookProvision dispatches the provision request to the configured webhook instead of calling this +// agent's own registered Provisioning implementation +func (h *managedApplication) onWebhookProvision(log log.FieldLogger, app *management.ManagedApplication, pma provManagedApp) error { + if webhookDispatchedFor(app, webhookOperationProvision) { + return nil + } + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationProvision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(app, err) + return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + } + markWebhookDispatched(app, webhookOperationProvision) + return h.client.CreateSubResource(app.ResourceMeta, app.SubResources) +} + func (h *managedApplication) provision(pma provManagedApp) prov.RequestStatus { status := h.prov.ApplicationRequestProvision(pma) resourceStatus := prov.NewStatusReason(status) @@ -212,10 +267,6 @@ func (h *managedApplication) provision(pma provManagedApp) prov.RequestStatus { func (h *managedApplication) onDeleting(ctx context.Context, app *management.ManagedApplication, pma provManagedApp) { log := getLoggerFromContext(ctx) - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(app, webhookOperationDeprovision) { - return - } - 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) @@ -223,18 +274,6 @@ func (h *managedApplication) onDeleting(ctx context.Context, app *management.Man return } - if h.webhookCfg.IsConfigured() { - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationDeprovision, pma)); err != nil { - log.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(app, err) - h.client.CreateSubResource(app.ResourceMeta, app.SubResources) - return - } - markWebhookDispatched(app, webhookOperationDeprovision) - h.client.CreateSubResource(app.ResourceMeta, app.SubResources) - return - } - status := h.prov.ApplicationRequestDeprovision(pma) if status.GetStatus() == prov.Success { ri, _ := app.AsInstance() @@ -248,6 +287,30 @@ func (h *managedApplication) onDeleting(ctx context.Context, app *management.Man } } +// onWebhookDeprovision dispatches the deprovision request to the configured webhook instead of calling +// this agent's own registered Provisioning implementation +func (h *managedApplication) onWebhookDeprovision(ctx context.Context, log log.FieldLogger, app *management.ManagedApplication, pma provManagedApp) { + if webhookDispatchedFor(app, webhookOperationDeprovision) { + return + } + + 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 + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationRequest(webhookOperationDeprovision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(app, err) + h.client.CreateSubResource(app.ResourceMeta, app.SubResources) + return + } + markWebhookDispatched(app, webhookOperationDeprovision) + h.client.CreateSubResource(app.ResourceMeta, app.SubResources) +} + // onError updates the managed app with an error status func (h *managedApplication) onError(ar *management.ManagedApplication, err error) { ps := prov.NewRequestStatusBuilder() @@ -328,3 +391,13 @@ func getConsumerOrgID(app *management.ManagedApplication) string { } return consumerOrgID } + +// hasFinalizer returns true if name is already present in finalizers +func hasFinalizer(finalizers []apiv1.Finalizer, name string) bool { + for _, f := range finalizers { + if f.Name == name { + return true + } + } + return false +} diff --git a/pkg/agent/handler/marketplacehandler.go b/pkg/agent/handler/marketplacehandler.go index fc8afd5d0..ee8b34957 100644 --- a/pkg/agent/handler/marketplacehandler.go +++ b/pkg/agent/handler/marketplacehandler.go @@ -3,6 +3,7 @@ package handler import ( v1 "github.com/Axway/agent-sdk/pkg/apic/apiserver/models/api/v1" prov "github.com/Axway/agent-sdk/pkg/apic/provisioning" + "github.com/Axway/agent-sdk/pkg/config" "github.com/Axway/agent-sdk/pkg/watchmanager/proto" ) @@ -12,6 +13,13 @@ func (m *marketplaceHandler) shouldProcessPending(status *v1.ResourceStatus, sta return status.Level == prov.Pending.String() && state != v1.ResourceDeleting } +// shouldProvisionWebhook returns true when the resource is pending and a provisioning webhook is +// configured for this resource type - i.e. this event should be dispatched to the webhook instead of +// the agent's own registered Provisioning implementation. +func (m *marketplaceHandler) shouldProvisionWebhook(status *v1.ResourceStatus, state string, cfg config.ProvisioningWebhookEndpointConfig) bool { + return m.shouldProcessPending(status, state) && cfg.IsConfigured() +} + func (m *marketplaceHandler) shouldIgnore(action proto.Event_Type, meta *proto.EventMeta) bool { if meta == nil { return false @@ -25,6 +33,13 @@ func (m *marketplaceHandler) shouldProcessDeleting(status *v1.ResourceStatus, st return status.Level == prov.Success.String() && state == v1.ResourceDeleting && len(finalizers) > 0 } +// shouldDeprovisionWebhook returns true when the resource is in a deleting state (with finalizers) and a +// provisioning webhook is configured for this resource type - i.e. this event should be dispatched to the +// webhook instead of the agent's own registered Provisioning implementation. +func (m *marketplaceHandler) shouldDeprovisionWebhook(status *v1.ResourceStatus, state string, finalizers []v1.Finalizer, cfg config.ProvisioningWebhookEndpointConfig) bool { + return m.shouldProcessDeleting(status, state, finalizers) && cfg.IsConfigured() +} + 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/webhook.go b/pkg/agent/handler/webhook.go index eee1ad761..2ad05c532 100644 --- a/pkg/agent/handler/webhook.go +++ b/pkg/agent/handler/webhook.go @@ -15,6 +15,16 @@ const webhookDispatchDetailKey = "provisioningWebhookDispatched" const ( webhookOperationProvision = "provision" webhookOperationDeprovision = "deprovision" + + // webhookStatusKey/webhookMessageKey are reserved keys the webhook writes inside x-webhook-details + // itself (not business data) to report whether its processing succeeded, since dispatch never waits + // for a response and the webhook is scoped away from writing the resource's own status subresource + // directly. + webhookStatusKey = "status" + webhookMessageKey = "message" + + webhookStatusSuccess = "success" + webhookStatusFailed = "failed" ) // subResourceCarrier is satisfied by any apiserver resource instance type (ManagedApplication, AccessRequest, @@ -29,8 +39,16 @@ type subResourceCarrier interface { // dispatched to the provisioning webhook - used to avoid re-dispatching on event redelivery (Central may // redeliver an event for a resource for reasons unrelated to anything this handler wrote). func webhookDispatchedFor(h subResourceCarrier, operation string) bool { + return webhookDispatchedOperation(h) == operation +} + +// webhookDispatchedOperation returns which operation ("provision"/"deprovision") was last dispatched to +// the provisioning webhook for this resource, or "" if none was. Since dispatch never waits for the +// webhook to actually finish, the x-webhook-details SUBRESOURCEUPDATED event is the only signal that +// processing is done - at that point, this tells the handler which operation just completed. +func webhookDispatchedOperation(h subResourceCarrier) string { v, _ := util.GetAgentDetailsValue(h, webhookDispatchDetailKey) - return v == operation + return v } // markWebhookDispatched records, in the resource's x-agent-details, that operation was just dispatched to @@ -41,6 +59,18 @@ func markWebhookDispatched(h subResourceCarrier, operation string) { _ = util.SetAgentDetailsKey(h, webhookDispatchDetailKey, operation) } +// webhookDetailsValue returns the string value of key from the resource's x-webhook-details subresource +// directly (not from x-agent-details, which may not have been mirrored into yet) - used to read the +// webhook's own reserved status/message keys. +func webhookDetailsValue(h subResourceCarrier, key string) string { + details, ok := h.GetSubResource(defs.XWebhookDetails).(map[string]interface{}) + if !ok { + return "" + } + v, _ := details[key].(string) + return v +} + // mirrorWebhookDetails copies the resource's x-webhook-details subresource into its x-agent-details, so // existing code (traceability lookups, etc.) that only knows how to read x-agent-details keeps working once // a provisioning webhook is configured - the webhook itself is scoped to write only x-webhook-details, not From ccec80a188abdc01e395ea7dce5f69ded2a59183 Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Fri, 11 Sep 2026 15:35:09 +0300 Subject: [PATCH 05/12] APIGOV-33010 access request improvements --- pkg/agent/handler/accessrequest.go | 204 +++++++++++++++++++++-------- 1 file changed, 151 insertions(+), 53 deletions(-) diff --git a/pkg/agent/handler/accessrequest.go b/pkg/agent/handler/accessrequest.go index 79d4cc2f2..5520c2ddc 100644 --- a/pkg/agent/handler/accessrequest.go +++ b/pkg/agent/handler/accessrequest.go @@ -121,17 +121,6 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return nil } - if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { - ar := &management.AccessRequest{} - if err := ar.FromInstance(resource); err != nil { - return nil - } - if mirrorWebhookDetails(ar) { - return h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) - } - return nil - } - log := getLoggerFromContext(ctx).WithComponent("accessRequestHandler") defer log.Trace("finished processing request") ctx = setLoggerInContext(ctx, log) @@ -143,6 +132,16 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return nil } + if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { + if webhookDispatchedFor(ar, webhookOperationProvision) { + return h.postWebhookProvisionProcess(log, ar) + } + if webhookDispatchedFor(ar, webhookOperationDeprovision) { + h.postWebhookDeprovisionProcess(log, ar) + } + return nil + } + // add or update the cache with the access request // migrated access request is not added to cache until processed for Pending if (action == proto.Event_CREATED || action == proto.Event_UPDATED) && ar.Spec.AccessRequest == "" { @@ -158,6 +157,13 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return nil } + if ok := h.shouldProvisionWebhook(ar.Status, ar.Metadata.State, h.webhookCfg); ok { + log.Trace("processing resource in pending status via provisioning webhook") + mar := h.getMigratingAccessRequest(ar) + h.onWebhookProvision(ctx, log, ar, mar) + return nil + } + if ok := h.shouldProcessPending(ar.Status, ar.Metadata.State); ok { mar := h.getMigratingAccessRequest(ar) @@ -190,6 +196,12 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return err } + if ok := h.shouldDeprovisionWebhook(ar.Status, ar.Metadata.State, ar.Finalizers, h.webhookCfg); ok { + log.Trace("processing resource in deleting state via provisioning webhook") + h.onWebhookDeprovision(ctx, log, ar) + return nil + } + if ok := h.shouldProcessDeleting(ar.Status, ar.Metadata.State, ar.Finalizers); ok { log.Trace("processing resource in deleting state") h.onDeleting(ctx, ar) @@ -198,50 +210,43 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta return nil } -func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) *management.AccessRequest { - log := getLoggerFromContext(ctx) - - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationProvision) { - return ar - } - +// buildAccessRequest fetches the managed app and access request definition, then builds the full +// provisioning request - shared by the classic and webhook-dispatch paths, which only differ in how they +// report a failure here. app/ard are also returned since callers need them beyond just building req +// (quota enforcement, secret data encryption). +func (h *accessRequestHandler) buildAccessRequest(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) (*management.ManagedApplication, *management.AccessRequestDefinition, *provAccReq, error) { app, err := h.getManagedApp(ctx, ar) if err != nil { - log.WithError(err).Error("error getting managed app") - h.onError(ctx, ar, err) - return ar + return nil, nil, nil, fmt.Errorf("error getting managed app: %w", err) } // check the application status if app.Status.Level != prov.Success.String() { - err = fmt.Errorf("error can't handle access request when application is not yet successful") - h.onError(ctx, ar, err) - return ar + return nil, nil, nil, fmt.Errorf("error can't handle access request when application is not yet successful") } ard, err := h.getARD(ctx, ar) if err != nil { - log.WithError(err).Errorf("error getting access request definition") - h.onError(ctx, ar, err) - return ar + return nil, nil, nil, fmt.Errorf("error getting access request definition: %w", err) } req, err := h.newReq(ctx, ar, mar, util.GetAgentDetails(app)) if err != nil { - log.WithError(err).Error("error getting resource details") - h.onError(ctx, ar, err) - return ar + return nil, nil, nil, fmt.Errorf("error getting resource details: %w", err) } updateDataFromEnumMap(ar.Spec.Data, ard.Spec.Schema) - if h.webhookCfg.IsConfigured() { - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationProvision, *req)); err != nil { - log.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(ctx, ar, err) - return ar - } - markWebhookDispatched(ar, webhookOperationProvision) + return app, ard, req, nil +} + +func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) *management.AccessRequest { + log := getLoggerFromContext(ctx) + + app, ard, req, err := h.buildAccessRequest(ctx, ar, mar) + if err != nil { + log.WithError(err).Error("error building access request") + h.onError(ctx, ar, err) return ar } @@ -301,6 +306,82 @@ func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.Acc return ar } +// onWebhookProvision dispatches the provision request to the configured webhook instead of calling this +// agent's own registered Provisioning implementation. Self-contained (builds its own request, persists its +// own result) since it's called directly from Handle, before onPending's classic-only path. +func (h *accessRequestHandler) onWebhookProvision(ctx context.Context, log log.FieldLogger, ar *management.AccessRequest, mar *apiv1.ResourceInstance) { + if webhookDispatchedFor(ar, webhookOperationProvision) { + return + } + + _, _, req, err := h.buildAccessRequest(ctx, ar, mar) + if err != nil { + log.WithError(err).Error("error building access request") + return + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationProvision, *req)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, ar, err) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + return + } + markWebhookDispatched(ar, webhookOperationProvision) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) +} + +// postWebhookProvisionProcess mirrors the webhook's x-webhook-details into x-agent-details, and adds the +// finalizer once status reflects success - status is the webhook's own responsibility (see +// docs/discovery/provisioning-webhook.md), so ar.Status already reflects the outcome by the time this +// runs. On success, also completes any pending AccessRequest migration/transfer: getMigratingAccessRequest +// is a stateless lookup keyed off ar.Spec.AccessRequest, so it's safe to resolve fresh here rather than +// needing whatever mar onWebhookProvision saw at dispatch time. ar is dropped from cache after that (not +// re-added) since the x-agent-details write below lets the already-decoupled accessRequestCacheHandler +// pick it back up fresh. Only called when the dispatched operation was a provision - see Handle. +func (h *accessRequestHandler) postWebhookProvisionProcess(log log.FieldLogger, ar *management.AccessRequest) error { + if ar.Status.Level == prov.Success.String() { + if !hasFinalizer(ar.Finalizers, arFinalizer) { + // only add finalizer on success + ri, _ := ar.AsInstance() + h.client.UpdateResourceFinalizer(ri, arFinalizer, "", true) + } + + if mar := h.getMigratingAccessRequest(ar); mar != nil { + h.client.UpdateResourceFinalizer(mar, arFinalizer, "", false) + if err := h.client.DeleteResourceInstance(mar); err != nil { + log.WithError(err).Error("failed to delete migrating access request") + } + h.cache.DeleteAccessRequest(ar.Metadata.ID) + } + } + if mirrorWebhookDetails(ar) { + return h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + } + return nil +} + +// postWebhookDeprovisionProcess removes the finalizer (if still present) and drops the access request +// from cache once the webhook reports it successfully completed a deprovision. ar.Status can't be used +// for this - it's whatever it already was when shouldDeprovisionWebhook let the event through, not a +// signal from the webhook - so success/failure is read from the reserved "status" (and, on failure, +// "message") keys the webhook writes inside x-webhook-details itself. Status itself is the webhook's own +// responsibility (see docs/discovery/provisioning-webhook.md), so on failure this only logs - it doesn't +// write anything. No mirroring here - deprovision typically has no data to report. Only called when the +// dispatched operation was a deprovision - see Handle. +func (h *accessRequestHandler) postWebhookDeprovisionProcess(log log.FieldLogger, ar *management.AccessRequest) { + if webhookDetailsValue(ar, webhookStatusKey) != webhookStatusSuccess { + message := webhookDetailsValue(ar, webhookMessageKey) + log.WithField("message", message).Error("provisioning webhook reported deprovision failure") + return + } + + if hasFinalizer(ar.Finalizers, arFinalizer) { + ri, _ := ar.AsInstance() + h.client.UpdateResourceFinalizer(ri, arFinalizer, "", false) + } + h.cache.DeleteAccessRequest(ar.Metadata.ID) +} + func (h *accessRequestHandler) provision(par *provAccReq) (prov.RequestStatus, prov.AccessData) { status, accessData := h.prov.AccessRequestProvision(par) if status.GetStatus() == prov.Success { @@ -336,10 +417,6 @@ func (h *accessRequestHandler) onError(_ context.Context, ar *management.AccessR func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.AccessRequest) { log := getLoggerFromContext(ctx) - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(ar, webhookOperationDeprovision) { - return - } - app, err := h.getManagedApp(ctx, ar) if err != nil { log.WithError(err).Error("error getting managed app") @@ -357,18 +434,6 @@ func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.Ac return } - if h.webhookCfg.IsConfigured() { - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationDeprovision, *req)); err != nil { - log.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(ctx, ar, err) - h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) - return - } - markWebhookDispatched(ar, webhookOperationDeprovision) - h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) - return - } - status := h.prov.AccessRequestDeprovision(req) if status.GetStatus() == prov.Success { h.client.UpdateResourceFinalizer(ri, arFinalizer, "", false) @@ -381,6 +446,39 @@ func (h *accessRequestHandler) onDeleting(ctx context.Context, ar *management.Ac } } +// onWebhookDeprovision dispatches the deprovision request to the configured webhook instead of calling +// this agent's own registered Provisioning implementation. Self-contained (builds its own request, +// persists its own result) since it's called directly from Handle, before onDeleting's classic-only path. +func (h *accessRequestHandler) onWebhookDeprovision(ctx context.Context, log log.FieldLogger, ar *management.AccessRequest) { + if webhookDispatchedFor(ar, webhookOperationDeprovision) { + return + } + + app, err := h.getManagedApp(ctx, ar) + if err != nil { + log.WithError(err).Error("error getting managed app") + return + } + + req, err := h.newReq(ctx, ar, nil, util.GetAgentDetails(app)) + if err != nil { + log.WithError(err).Debug("removing finalizers on the access request") + ri, _ := ar.AsInstance() + h.client.UpdateResourceFinalizer(ri, arFinalizer, "", false) + h.cache.DeleteAccessRequest(ri.Metadata.ID) + return + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookAccessRequest(webhookOperationDeprovision, *req)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, ar, err) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) + return + } + markWebhookDispatched(ar, webhookOperationDeprovision) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) +} + func (h *accessRequestHandler) getManagedApp(_ context.Context, ar *management.AccessRequest) (*management.ManagedApplication, error) { app := management.NewManagedApplication(ar.Spec.ManagedApplication, ar.Metadata.Scope.Name) ri, err := h.client.GetResource(app.GetSelfLink()) From 4c9c03b864a75bd8abf76cfc042249fcedb55473 Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Fri, 11 Sep 2026 17:12:14 +0300 Subject: [PATCH 06/12] APIGOV-33010 credential improvements --- pkg/agent/handler/accessrequest.go | 4 + pkg/agent/handler/credential.go | 227 ++++++++++++++++++++++++----- 2 files changed, 192 insertions(+), 39 deletions(-) diff --git a/pkg/agent/handler/accessrequest.go b/pkg/agent/handler/accessrequest.go index 5520c2ddc..9a53a1bfc 100644 --- a/pkg/agent/handler/accessrequest.go +++ b/pkg/agent/handler/accessrequest.go @@ -317,6 +317,8 @@ func (h *accessRequestHandler) onWebhookProvision(ctx context.Context, log log.F _, _, req, err := h.buildAccessRequest(ctx, ar, mar) if err != nil { log.WithError(err).Error("error building access request") + h.onError(ctx, ar, err) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) return } @@ -457,6 +459,8 @@ func (h *accessRequestHandler) onWebhookDeprovision(ctx context.Context, log log app, err := h.getManagedApp(ctx, ar) if err != nil { log.WithError(err).Error("error getting managed app") + h.onError(ctx, ar, err) + h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) return } diff --git a/pkg/agent/handler/credential.go b/pkg/agent/handler/credential.go index 26aad844e..9cfe1aea9 100644 --- a/pkg/agent/handler/credential.go +++ b/pkg/agent/handler/credential.go @@ -104,8 +104,11 @@ func (h *credentials) Handle(ctx context.Context, meta *proto.EventMeta, resourc action := GetActionFromContext(ctx) if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { - if mirrorWebhookDetails(cr) { - return h.client.CreateSubResource(cr.ResourceMeta, cr.SubResources) + if webhookDispatchedFor(cr, webhookOperationProvision) || webhookDispatchedFor(cr, update) { + return h.postWebhookProvisionProcess(cr) + } + if webhookDispatchedFor(cr, webhookOperationDeprovision) { + h.postWebhookDeprovisionProcess(logger, cr) } return nil } @@ -115,6 +118,11 @@ func (h *credentials) Handle(ctx context.Context, meta *proto.EventMeta, resourc return nil } + if ok := h.shouldDeprovisionWebhook(cr); ok { + logger.Trace("processing resource in deleting state via provisioning webhook") + return h.onWebhookDeprovision(ctx, cr) + } + if ok := h.shouldProcessDeleting(cr); ok { logger.Trace("processing resource in deleting state") h.onDeleting(ctx, cr) @@ -131,6 +139,16 @@ func (h *credentials) Handle(ctx context.Context, meta *proto.EventMeta, resourc } } + if ok := h.shouldProvisionWebhook(cr); ok { + logger.Trace("processing resource in pending status via provisioning webhook") + return h.onWebhookProvision(ctx, cr) + } + + if actions := h.shouldUpdateWebhook(cr); len(actions) != 0 { + logger.Trace("processing resource in updating status via provisioning webhook") + return h.onWebhookUpdate(ctx, cr, actions) + } + var credential *management.Credential if ok := h.shouldProcessPending(cr); ok { logger.Trace("processing resource in pending status") @@ -180,6 +198,13 @@ func (h *credentials) shouldProcessDeleting(cr *management.Credential) bool { return false } +// shouldDeprovisionWebhook returns true when the credential is deleting and a provisioning webhook is +// configured - i.e. this event should be dispatched to the webhook instead of the agent's own registered +// Provisioning implementation. +func (h *credentials) shouldDeprovisionWebhook(cr *management.Credential) bool { + return h.shouldProcessDeleting(cr) && h.webhookCfg.IsConfigured() +} + // shouldProvision // Status.Level = Pending and // Metadata.State = !Deleting and @@ -193,6 +218,23 @@ func (h *credentials) shouldProcessPending(cr *management.Credential) bool { return false } +// shouldProvisionWebhook returns true when the credential is pending and a provisioning webhook is +// configured - i.e. this event should be dispatched to the webhook instead of the agent's own registered +// Provisioning implementation. +func (h *credentials) shouldProvisionWebhook(cr *management.Credential) bool { + return h.shouldProcessPending(cr) && h.webhookCfg.IsConfigured() +} + +// shouldUpdateWebhook returns the pending credential update actions when a provisioning webhook is +// configured - i.e. this event should be dispatched to the webhook instead of the agent's own registered +// Provisioning implementation. +func (h *credentials) shouldUpdateWebhook(cr *management.Credential) []prov.CredentialAction { + if !h.webhookCfg.IsConfigured() { + return nil + } + return h.shouldProcessUpdating(cr) +} + // shouldProcessUpdating func (h *credentials) shouldProcessUpdating(cr *management.Credential) []prov.CredentialAction { actions := []prov.CredentialAction{} @@ -226,45 +268,84 @@ func (h *credentials) shouldProcessUpdating(cr *management.Credential) []prov.Cr func (h *credentials) onDeleting(ctx context.Context, cred *management.Credential) { logger := getLoggerFromContext(ctx) - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationDeprovision) { + app, provCreds, err := h.buildDeprovisionCreds(ctx, cred) + if err != nil { + h.onError(ctx, cred, err) return } + status := h.prov.CredentialDeprovision(provCreds) + + h.deprovisionPostProcess(status, provCreds, logger, ctx, cred, app) +} + +// buildDeprovisionCreds fetches the credential request definition and managed app, and builds the +// deprovisioning request - shared by the classic and webhook-dispatch paths, which only differ in how they +// report a failure here (classic reports via onError, webhook logs only since setting credential status is +// the webhook's responsibility). +func (h *credentials) buildDeprovisionCreds(ctx context.Context, cred *management.Credential) (*management.ManagedApplication, *provCreds, error) { + logger := getLoggerFromContext(ctx) + crd, err := h.getCRD(ctx, cred) if err != nil { logger.WithError(err).Error("error getting credential request definition") - h.onError(ctx, cred, err) - return + return nil, nil, err } app, err := h.getManagedApp(ctx, cred) if err != nil { logger.WithError(err).Error("error getting managed app") - h.onError(ctx, cred, err) - return + return nil, nil, err } - provCreds, err := h.newProvCreds(cred, app, 0, crd) if err != nil { logger.WithError(err).Error("error preparing credential request") + return nil, nil, err + } + return app, provCreds, nil +} + +// onWebhookDeprovision dispatches the deprovision request to the configured webhook instead of calling this +// agent's own registered Provisioning implementation. +func (h *credentials) onWebhookDeprovision(ctx context.Context, cred *management.Credential) error { + logger := getLoggerFromContext(ctx) + + if webhookDispatchedFor(cred, webhookOperationDeprovision) { + return nil + } + + _, provCreds, err := h.buildDeprovisionCreds(ctx, cred) + if err != nil { h.onError(ctx, cred, err) - return + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) } - if h.webhookCfg.IsConfigured() { - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationDeprovision, provCreds)); err != nil { - logger.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(ctx, cred, err) - h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) - return - } - markWebhookDispatched(cred, webhookOperationDeprovision) - h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationDeprovision, provCreds)); err != nil { + logger.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, cred, err) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + markWebhookDispatched(cred, webhookOperationDeprovision) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) +} + +// postWebhookDeprovisionProcess removes the finalizer once the webhook reports it successfully completed a +// deprovision. cred.Status can't be used for this - it's whatever it already was when +// shouldDeprovisionWebhook let the event through, not a signal from the webhook - so success/failure is read +// from the reserved "status" (and, on failure, "message") keys the webhook writes inside x-webhook-details +// itself. Status itself is the webhook's own responsibility (see docs/discovery/provisioning-webhook.md), so +// on failure this only logs - it doesn't write anything. Only called when the dispatched operation was a +// deprovision - see Handle. +func (h *credentials) postWebhookDeprovisionProcess(log log.FieldLogger, cred *management.Credential) { + if webhookDetailsValue(cred, webhookStatusKey) != webhookStatusSuccess { + message := webhookDetailsValue(cred, webhookMessageKey) + log.WithField("message", message).Error("provisioning webhook reported deprovision failure") return } - status := h.prov.CredentialDeprovision(provCreds) - - h.deprovisionPostProcess(status, provCreds, logger, ctx, cred, app) + if hasAgentCredentialFinalizer(cred.Finalizers) { + ri, _ := cred.AsInstance() + h.client.UpdateResourceFinalizer(ri, crFinalizer, "", false) + } } func (h *credentials) deprovisionPostProcess(status prov.RequestStatus, provCreds *provCreds, logger log.FieldLogger, @@ -316,10 +397,6 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential // check the application status logger := getLoggerFromContext(ctx) - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(cred, webhookOperationProvision) { - return cred - } - app, crd, shouldReturn := h.provisionPreProcess(ctx, cred) if shouldReturn { return cred @@ -338,20 +415,6 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential return cred } - if h.webhookCfg.IsConfigured() { - // normal (non-webhook) provisioning always sets this so agents-controller can schedule credential - // expiry off the status subresource; set it here too, before dispatch, so it's already present in - // x-agent-details by the time mirrorWebhookDetails runs. - util.SetAgentDetailsKey(cred, prov.HandleCredentialExpiry, "true") - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationProvision, provCreds)); err != nil { - logger.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(ctx, cred, err) - return cred - } - markWebhookDispatched(cred, webhookOperationProvision) - return cred - } - status, credentialData := h.provision(provCreds) h.provisionPostProcess(status, credentialData, app, crd, provCreds, cred) @@ -359,6 +422,57 @@ func (h *credentials) onPending(ctx context.Context, cred *management.Credential return cred } +// onWebhookProvision dispatches the provision request to the configured webhook instead of calling this +// agent's own registered Provisioning implementation. Unlike onPending, it does not call registerIDPClient - +// IDP client registration is the webhook's own responsibility in webhook mode. +func (h *credentials) onWebhookProvision(ctx context.Context, cred *management.Credential) error { + logger := getLoggerFromContext(ctx) + + if webhookDispatchedFor(cred, webhookOperationProvision) { + return nil + } + + app, crd, shouldReturn := h.provisionPreProcess(ctx, cred) + if shouldReturn { + return nil + } + + provCreds, err := h.newProvCreds(cred, app, 0, crd) + if err != nil { + logger.WithError(err).Error("error preparing credential request") + h.onError(ctx, cred, err) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(webhookOperationProvision, provCreds)); err != nil { + logger.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, cred, err) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + markWebhookDispatched(cred, webhookOperationProvision) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) +} + +// postWebhookProvisionProcess mirrors the webhook's x-webhook-details into x-agent-details, and adds the +// finalizer once status reflects success - status is the webhook's own responsibility (see +// docs/discovery/provisioning-webhook.md), so cred.Status already reflects the outcome by the time this +// runs. Only called when the dispatched operation was a provision - see Handle. +func (h *credentials) postWebhookProvisionProcess(cred *management.Credential) error { + // normal (non-webhook) provisioning always sets this so agents-controller can schedule credential + // expiry off the status subresource + util.SetAgentDetailsKey(cred, prov.HandleCredentialExpiry, "true") + + if cred.Status.Level == prov.Success.String() && !hasAgentCredentialFinalizer(cred.Finalizers) { + // only add finalizer on success + ri, _ := cred.AsInstance() + h.client.UpdateResourceFinalizer(ri, crFinalizer, "", true) + } + if mirrorWebhookDetails(cred) { + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + return nil +} + func (h *credentials) provisionPreProcess(ctx context.Context, cred *management.Credential) (*management.ManagedApplication, *management.CredentialRequestDefinition, bool) { logger := getLoggerFromContext(ctx) app, err := h.getManagedApp(ctx, cred) @@ -542,6 +656,41 @@ func (h *credentials) onUpdates(ctx context.Context, cred *management.Credential return cred } +// onWebhookUpdate dispatches pending credential update actions (suspend/rotate/enable) to the configured +// webhook instead of calling this agent's own registered Provisioning implementation. Unlike onUpdates, it +// does not call registerIDPClient on rotate - IDP client registration is the webhook's own responsibility +// in webhook mode. +func (h *credentials) onWebhookUpdate(ctx context.Context, cred *management.Credential, actions []prov.CredentialAction) error { + logger := getLoggerFromContext(ctx) + + if webhookDispatchedFor(cred, update) { + return nil + } + + app, crd, shouldReturn := h.provisionPreProcess(ctx, cred) + if shouldReturn { + return nil + } + + for _, action := range actions { + provCreds, err := h.newProvCreds(cred, app, action, crd) + if err != nil { + logger.WithError(err).Error("error preparing credential request") + h.onError(ctx, cred, err) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookCredentialRequest(update, provCreds)); err != nil { + logger.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, cred, err) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) + } + } + + markWebhookDispatched(cred, update) + return h.client.CreateSubResource(cred.ResourceMeta, cred.SubResources) +} + // isExternalCredential - when mode is CredProvisionModeExternal the client was registered outside the SDK; skip RegisterClient. func isExternalCredential(cred *management.Credential) bool { if cred == nil { From 3422794790202ae52de7357eeec1e6677e2ec37e Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Fri, 11 Sep 2026 17:26:18 +0300 Subject: [PATCH 07/12] APIGOV-33010 return err --- pkg/agent/handler/accessrequest.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/agent/handler/accessrequest.go b/pkg/agent/handler/accessrequest.go index 9a53a1bfc..45277e651 100644 --- a/pkg/agent/handler/accessrequest.go +++ b/pkg/agent/handler/accessrequest.go @@ -215,24 +215,31 @@ func (h *accessRequestHandler) Handle(ctx context.Context, meta *proto.EventMeta // report a failure here. app/ard are also returned since callers need them beyond just building req // (quota enforcement, secret data encryption). func (h *accessRequestHandler) buildAccessRequest(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) (*management.ManagedApplication, *management.AccessRequestDefinition, *provAccReq, error) { + log := getLoggerFromContext(ctx) + app, err := h.getManagedApp(ctx, ar) if err != nil { - return nil, nil, nil, fmt.Errorf("error getting managed app: %w", err) + log.WithError(err).Error("error getting managed app") + return nil, nil, nil, err } // check the application status if app.Status.Level != prov.Success.String() { - return nil, nil, nil, fmt.Errorf("error can't handle access request when application is not yet successful") + err := errors.New("error can't handle access request when application is not yet successful") + log.WithError(err).Error("error checking application status") + return nil, nil, nil, err } ard, err := h.getARD(ctx, ar) if err != nil { - return nil, nil, nil, fmt.Errorf("error getting access request definition: %w", err) + log.WithError(err).Error("error getting access request definition") + return nil, nil, nil, err } req, err := h.newReq(ctx, ar, mar, util.GetAgentDetails(app)) if err != nil { - return nil, nil, nil, fmt.Errorf("error getting resource details: %w", err) + log.WithError(err).Error("error getting resource details") + return nil, nil, nil, err } updateDataFromEnumMap(ar.Spec.Data, ard.Spec.Schema) @@ -241,11 +248,8 @@ func (h *accessRequestHandler) buildAccessRequest(ctx context.Context, ar *manag } func (h *accessRequestHandler) onPending(ctx context.Context, ar *management.AccessRequest, mar *apiv1.ResourceInstance) *management.AccessRequest { - log := getLoggerFromContext(ctx) - app, ard, req, err := h.buildAccessRequest(ctx, ar, mar) if err != nil { - log.WithError(err).Error("error building access request") h.onError(ctx, ar, err) return ar } @@ -316,7 +320,6 @@ func (h *accessRequestHandler) onWebhookProvision(ctx context.Context, log log.F _, _, req, err := h.buildAccessRequest(ctx, ar, mar) if err != nil { - log.WithError(err).Error("error building access request") h.onError(ctx, ar, err) h.client.CreateSubResource(ar.ResourceMeta, ar.SubResources) return From 1d6e59a00d30bf3066d3082d56280dad9072eee4 Mon Sep 17 00:00:00 2001 From: Alin Rosca Date: Fri, 11 Sep 2026 18:07:50 +0300 Subject: [PATCH 08/12] APIGOV-33010 managed app profile improvements --- .../handler/managedapplicationprofile.go | 71 ++++++++++++++----- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/pkg/agent/handler/managedapplicationprofile.go b/pkg/agent/handler/managedapplicationprofile.go index f456854e4..3c136169c 100644 --- a/pkg/agent/handler/managedapplicationprofile.go +++ b/pkg/agent/handler/managedapplicationprofile.go @@ -86,8 +86,8 @@ func (h *managedApplicationProfile) Handle(ctx context.Context, meta *proto.Even action := GetActionFromContext(ctx) if action == proto.Event_SUBRESOURCEUPDATED && meta.GetSubresource() == defs.XWebhookDetails { - if mirrorWebhookDetails(profile) { - return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + if webhookDispatchedFor(profile, webhookOperationProvision) { + return h.postWebhookProvisionProcess(profile) } return nil } @@ -97,6 +97,11 @@ func (h *managedApplicationProfile) Handle(ctx context.Context, meta *proto.Even return nil } + if ok := h.shouldProvisionWebhook(profile.Status, profile.Metadata.State, h.webhookCfg); ok { + log.Trace("processing resource in pending status via provisioning webhook") + return h.onWebhookProvision(ctx, log, profile) + } + if ok := h.shouldProcessPending(profile.Status, profile.Metadata.State); ok { log.Trace("processing resource in pending status") return h.onPending(ctx, profile) @@ -105,23 +110,28 @@ func (h *managedApplicationProfile) Handle(ctx context.Context, meta *proto.Even return nil } -func (h *managedApplicationProfile) onPending(ctx context.Context, profile *management.ManagedApplicationProfile) error { - log := getLoggerFromContext(ctx) - - if h.webhookCfg.IsConfigured() && webhookDispatchedFor(profile, webhookOperationProvision) { - return nil +// postWebhookProvisionProcess mirrors the webhook's x-webhook-details into x-agent-details - status is the +// webhook's own responsibility (see docs/discovery/provisioning-webhook-responsibilities.md). Only called +// when the dispatched operation was a provision - see Handle. ManagedApplicationProfile has no finalizer or +// deprovisioning concept, so there's nothing else to reclaim here. +func (h *managedApplicationProfile) postWebhookProvisionProcess(profile *management.ManagedApplicationProfile) error { + if mirrorWebhookDetails(profile) { + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) } + return nil +} +// buildProvManagedAppProfile fetches the managed app and builds the profile provisioning request - shared +// by the classic and webhook-dispatch paths, which only differ in how they report a failure here. +func (h *managedApplicationProfile) buildProvManagedAppProfile(ctx context.Context, profile *management.ManagedApplicationProfile) (provManagedAppProfile, error) { app, err := h.getManagedApp(ctx, profile) if err != nil { - log.WithError(err).Error("error getting managed app") - h.onError(ctx, profile, err) - return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + return provManagedAppProfile{}, err } h.checkForEnumValueMap(ctx, profile.Spec.Data, profile.Spec.ApplicationProfileDefinition) - pma := provManagedAppProfile{ + return provManagedAppProfile{ attributes: profile.Spec.Data, profileDefinition: profile.Spec.ApplicationProfileDefinition, managedAppName: app.Name, @@ -129,15 +139,40 @@ func (h *managedApplicationProfile) onPending(ctx context.Context, profile *mana data: util.GetAgentDetails(app), consumerOrgID: getConsumerOrgID(app), id: app.Metadata.ID, + }, nil +} + +// onWebhookProvision dispatches the provision request to the configured webhook instead of calling this +// agent's own registered Provisioning implementation. Self-contained (builds its own request, persists its +// own result) since it's called directly from Handle, before onPending's classic-only path. +func (h *managedApplicationProfile) onWebhookProvision(ctx context.Context, log log.FieldLogger, profile *management.ManagedApplicationProfile) error { + if webhookDispatchedFor(profile, webhookOperationProvision) { + return nil } - if h.webhookCfg.IsConfigured() { - if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationProfileRequest(webhookOperationProvision, pma)); err != nil { - log.WithError(err).Error("provisioning webhook dispatch failed") - h.onError(ctx, profile, err) - return h.client.CreateSubResource(profile.ResourceMeta, map[string]interface{}{"status": profile.Status}) - } - markWebhookDispatched(profile, webhookOperationProvision) + pma, err := h.buildProvManagedAppProfile(ctx, profile) + if err != nil { + log.WithError(err).Error("error getting managed app") + h.onError(ctx, profile, err) + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + } + + if err := provisioningwebhook.Dispatch(h.webhookClient, h.webhookCfg, newWebhookApplicationProfileRequest(webhookOperationProvision, pma)); err != nil { + log.WithError(err).Error("provisioning webhook dispatch failed") + h.onError(ctx, profile, err) + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) + } + markWebhookDispatched(profile, webhookOperationProvision) + return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) +} + +func (h *managedApplicationProfile) onPending(ctx context.Context, profile *management.ManagedApplicationProfile) error { + log := getLoggerFromContext(ctx) + + pma, err := h.buildProvManagedAppProfile(ctx, profile) + if err != nil { + log.WithError(err).Error("error getting managed app") + h.onError(ctx, profile, err) return h.client.CreateSubResource(profile.ResourceMeta, profile.SubResources) } From a00cb884cb48325d81739d8d946ca25e92d6c47b Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 14 Sep 2026 14:05:18 -0700 Subject: [PATCH 09/12] add limits to config and unit test --- pkg/config/provisioningwebhookconfig.go | 2 +- pkg/config/provisioningwebhookconfig_test.go | 32 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pkg/config/provisioningwebhookconfig.go b/pkg/config/provisioningwebhookconfig.go index 72d6af6e1..ea72048d2 100644 --- a/pkg/config/provisioningwebhookconfig.go +++ b/pkg/config/provisioningwebhookconfig.go @@ -190,7 +190,7 @@ func addSingleProvisioningWebhookProperties(props properties.Properties, path, r props.AddStringProperty(path+".password", "", "Password for "+resourceType+" provisioning webhook basic auth") props.AddStringProperty(path+".apiKeyHeader", "", "Header name used to send the "+resourceType+" provisioning webhook API key") props.AddStringProperty(path+".apiKeyValue", "", "API key value for the "+resourceType+" provisioning webhook") - props.AddIntProperty(path+".retryCount", 0, "Number of additional attempts to make if the "+resourceType+" provisioning webhook call fails") + props.AddIntProperty(path+".retryCount", 0, "Number of additional attempts to make if the "+resourceType+" provisioning webhook call fails", properties.WithLowerLimitInt(0), properties.WithUpperLimitInt(5)) } func parseProvisioningWebhookConfig(props properties.Properties) ProvisioningWebhookConfig { diff --git a/pkg/config/provisioningwebhookconfig_test.go b/pkg/config/provisioningwebhookconfig_test.go index 80919d80c..c1097c6ea 100644 --- a/pkg/config/provisioningwebhookconfig_test.go +++ b/pkg/config/provisioningwebhookconfig_test.go @@ -3,6 +3,8 @@ package config import ( "testing" + "github.com/Axway/agent-sdk/pkg/cmd/properties" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -109,6 +111,36 @@ func TestProvisioningWebhookConfigAuthTypes(t *testing.T) { } } +func TestProvisioningWebhookConfigRetryCountLimits(t *testing.T) { + // retryCount is registered with WithLowerLimitInt(0)/WithUpperLimitInt(5); values outside that + // range fall back to the default (0) rather than clamping to the nearest limit. + tests := []struct { + name string + value string + expected int + }{ + {name: "below lower limit falls back to default", value: "-1", expected: 0}, + {name: "at lower limit", value: "0", expected: 0}, + {name: "within range", value: "3", expected: 3}, + {name: "at upper limit", value: "5", expected: 5}, + {name: "above upper limit falls back to default", value: "6", expected: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd := &cobra.Command{Use: "test"} + props := properties.NewProperties(rootCmd) + addProvisioningWebhookConfigProperties(props) + + err := rootCmd.Flags().Set("centralProvisioningWebhookCredentialRetryCount", tt.value) + assert.Nil(t, err) + + cfg := parseProvisioningWebhookConfig(props) + assert.Equal(t, tt.expected, cfg.GetCredentialWebhook().GetRetryCount()) + }) + } +} + func TestProvisioningWebhookConfigOnlyOneConfigured(t *testing.T) { cfg := &ProvisioningWebhookConfiguration{ ManagedApplication: &ProvisioningWebhookEndpointConfiguration{WebhookConfiguration: &WebhookConfiguration{Type: "provisioningWebhook.managedApplication"}}, From be71028335368fbc54b03c22478990a45fc9be6c Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 14 Sep 2026 14:05:31 -0700 Subject: [PATCH 10/12] add webhook unit test --- pkg/agent/handler/webhook_test.go | 352 ++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 pkg/agent/handler/webhook_test.go diff --git a/pkg/agent/handler/webhook_test.go b/pkg/agent/handler/webhook_test.go new file mode 100644 index 000000000..8a642fc1c --- /dev/null +++ b/pkg/agent/handler/webhook_test.go @@ -0,0 +1,352 @@ +package handler + +import ( + "testing" + + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" + "github.com/Axway/agent-sdk/pkg/apic/provisioning" + "github.com/Axway/agent-sdk/pkg/authz/oauth" + corecfg "github.com/Axway/agent-sdk/pkg/config" + "github.com/stretchr/testify/assert" +) + +// mockSubResourceCarrier is a minimal subResourceCarrier for testing the webhook helpers without +// depending on a concrete apiserver resource type. +type mockSubResourceCarrier struct { + subResources map[string]interface{} +} + +func newMockSubResourceCarrier() *mockSubResourceCarrier { + return &mockSubResourceCarrier{subResources: map[string]interface{}{}} +} + +func (m *mockSubResourceCarrier) GetSubResource(key string) interface{} { + return m.subResources[key] +} + +func (m *mockSubResourceCarrier) SetSubResource(key string, resource interface{}) { + m.subResources[key] = resource +} + +func TestWebhookDispatchedFor(t *testing.T) { + tests := []struct { + name string + dispatch string + operation string + expected bool + }{ + {name: "matches dispatched operation", dispatch: webhookOperationProvision, operation: webhookOperationProvision, expected: true}, + {name: "does not match different operation", dispatch: webhookOperationProvision, operation: webhookOperationDeprovision, expected: false}, + {name: "nothing dispatched yet", dispatch: "", operation: webhookOperationProvision, expected: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newMockSubResourceCarrier() + if tc.dispatch != "" { + markWebhookDispatched(h, tc.dispatch) + } + assert.Equal(t, tc.expected, webhookDispatchedFor(h, tc.operation)) + }) + } +} + +func TestWebhookDispatchedOperation(t *testing.T) { + h := newMockSubResourceCarrier() + assert.Equal(t, "", webhookDispatchedOperation(h)) + + markWebhookDispatched(h, webhookOperationProvision) + assert.Equal(t, webhookOperationProvision, webhookDispatchedOperation(h)) + + markWebhookDispatched(h, webhookOperationDeprovision) + assert.Equal(t, webhookOperationDeprovision, webhookDispatchedOperation(h)) +} + +func TestMarkWebhookDispatched(t *testing.T) { + h := newMockSubResourceCarrier() + markWebhookDispatched(h, webhookOperationProvision) + + details, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, webhookOperationProvision, details[webhookDispatchDetailKey]) +} + +func TestWebhookDetailsValue(t *testing.T) { + h := newMockSubResourceCarrier() + assert.Equal(t, "", webhookDetailsValue(h, webhookStatusKey)) + + h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ + webhookStatusKey: webhookStatusSuccess, + webhookMessageKey: "all good", + }) + assert.Equal(t, webhookStatusSuccess, webhookDetailsValue(h, webhookStatusKey)) + assert.Equal(t, "all good", webhookDetailsValue(h, webhookMessageKey)) + assert.Equal(t, "", webhookDetailsValue(h, "missing-key")) + + // non-string values are ignored + h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{webhookStatusKey: 1}) + assert.Equal(t, "", webhookDetailsValue(h, webhookStatusKey)) +} + +func TestMirrorWebhookDetails(t *testing.T) { + t.Run("no webhook details to mirror", func(t *testing.T) { + h := newMockSubResourceCarrier() + assert.False(t, mirrorWebhookDetails(h)) + }) + + t.Run("empty webhook details map", func(t *testing.T) { + h := newMockSubResourceCarrier() + h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{}) + assert.False(t, mirrorWebhookDetails(h)) + }) + + t.Run("mirrors into new agent details", func(t *testing.T) { + h := newMockSubResourceCarrier() + h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ + webhookStatusKey: webhookStatusSuccess, + }) + + assert.True(t, mirrorWebhookDetails(h)) + + agentDetails, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, webhookStatusSuccess, agentDetails[webhookStatusKey]) + }) + + t.Run("merges into existing agent details without dropping other keys", func(t *testing.T) { + h := newMockSubResourceCarrier() + h.SetSubResource(defs.XAgentDetails, map[string]interface{}{"existing": "value"}) + h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ + webhookStatusKey: webhookStatusFailed, + }) + + assert.True(t, mirrorWebhookDetails(h)) + + agentDetails, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "value", agentDetails["existing"]) + assert.Equal(t, webhookStatusFailed, agentDetails[webhookStatusKey]) + }) +} + +func TestNewWebhookApplicationRequest(t *testing.T) { + app := provManagedApp{ + id: "app-id", + managedAppName: "my-app", + teamName: "my-team", + consumerOrgID: "org-id", + data: map[string]interface{}{"foo": "bar"}, + } + + req := newWebhookApplicationRequest(webhookOperationProvision, app) + + assert.Equal(t, webhookOperationProvision, req.Operation) + assert.Equal(t, "app-id", req.ID) + assert.Equal(t, "my-app", req.ManagedApplicationName) + assert.Equal(t, "my-team", req.TeamName) + assert.Equal(t, "org-id", req.ConsumerOrgID) + assert.Equal(t, app.data, req.AgentDetails) +} + +func TestNewWebhookApplicationProfileRequest(t *testing.T) { + profile := provManagedAppProfile{ + id: "profile-id", + managedAppName: "my-app", + profileDefinition: "my-profile-def", + teamName: "my-team", + consumerOrgID: "org-id", + attributes: map[string]interface{}{"attr": "val"}, + data: map[string]interface{}{"foo": "bar"}, + } + + req := newWebhookApplicationProfileRequest(webhookOperationDeprovision, profile) + + assert.Equal(t, webhookOperationDeprovision, req.Operation) + assert.Equal(t, "profile-id", req.ID) + assert.Equal(t, "my-app", req.ManagedApplicationName) + assert.Equal(t, "my-profile-def", req.ApplicationProfileDefinition) + assert.Equal(t, "my-team", req.TeamName) + assert.Equal(t, "org-id", req.ConsumerOrgID) + assert.Equal(t, profile.attributes, req.Attributes) + assert.Equal(t, profile.data, req.ApplicationDetails) +} + +// mockQuota is a minimal provisioning.Quota implementation for testing. +type mockQuota struct { + limit int64 + interval string +} + +func (q *mockQuota) GetInterval() provisioning.QuotaInterval { return 0 } +func (q *mockQuota) GetIntervalString() string { return q.interval } +func (q *mockQuota) GetLimit() int64 { return q.limit } +func (q *mockQuota) GetPlanName() string { return "" } + +func TestNewWebhookAccessRequest(t *testing.T) { + t.Run("without quota, not transferring", func(t *testing.T) { + r := provAccReq{ + id: "ar-id", + managedApp: "my-app", + requestData: map[string]interface{}{"req": "data"}, + provData: "prov-data", + accessDetails: map[string]interface{}{"access": "details"}, + refAccessDetails: map[string]interface{}{"ref": "details"}, + appDetails: map[string]interface{}{"app": "details"}, + instanceDetails: map[string]interface{}{"instance": "details"}, + } + + req := newWebhookAccessRequest(webhookOperationProvision, r) + + assert.Equal(t, webhookOperationProvision, req.Operation) + assert.Equal(t, "ar-id", req.ID) + assert.Equal(t, "", req.ReferencedID) + assert.Equal(t, "my-app", req.ManagedApplicationName) + assert.False(t, req.IsTransferring) + assert.Equal(t, r.requestData, req.RequestData) + assert.Equal(t, r.provData, req.ProvisioningData) + assert.Equal(t, r.accessDetails, req.AccessDetails) + assert.Equal(t, r.refAccessDetails, req.ReferencedAccessDetails) + assert.Equal(t, r.appDetails, req.ApplicationDetails) + assert.Equal(t, r.instanceDetails, req.InstanceDetails) + assert.Nil(t, req.Quota) + }) + + t.Run("transferring when refID set", func(t *testing.T) { + r := provAccReq{id: "ar-id", refID: "ref-id"} + req := newWebhookAccessRequest(webhookOperationProvision, r) + assert.Equal(t, "ref-id", req.ReferencedID) + assert.True(t, req.IsTransferring) + }) + + t.Run("with quota", func(t *testing.T) { + r := provAccReq{ + id: "ar-id", + quota: &mockQuota{limit: 100, interval: "daily"}, + } + req := newWebhookAccessRequest(webhookOperationProvision, r) + if assert.NotNil(t, req.Quota) { + assert.Equal(t, int64(100), req.Quota.Limit) + assert.Equal(t, "daily", req.Quota.Interval) + } + }) +} + +// mockIDPCredentialData is a minimal provisioning.IDPCredentialData implementation for testing. +type mockIDPCredentialData struct { + clientID string +} + +func (m *mockIDPCredentialData) GetClientID() string { return m.clientID } +func (m *mockIDPCredentialData) GetClientSecret() string { return "" } +func (m *mockIDPCredentialData) GetScopes() []string { return nil } +func (m *mockIDPCredentialData) GetGrantTypes() []string { return nil } +func (m *mockIDPCredentialData) GetTokenEndpointAuthMethod() string { return "" } +func (m *mockIDPCredentialData) GetResponseTypes() []string { return nil } +func (m *mockIDPCredentialData) GetRedirectURIs() []string { return nil } +func (m *mockIDPCredentialData) GetJwksURI() string { return "" } +func (m *mockIDPCredentialData) GetPublicKey() string { return "" } +func (m *mockIDPCredentialData) GetCertificate() string { return "" } +func (m *mockIDPCredentialData) GetCertificateMetadata() string { return "" } +func (m *mockIDPCredentialData) GetTLSClientAuthSanDNS() string { return "" } +func (m *mockIDPCredentialData) GetTLSClientAuthSanEmail() string { return "" } +func (m *mockIDPCredentialData) GetTLSClientAuthSanIP() string { return "" } +func (m *mockIDPCredentialData) GetTLSClientAuthSanURI() string { return "" } + +// mockOauthProvider is a minimal oauth.Provider implementation for testing. +type mockOauthProvider struct { + tokenEndpoint string +} + +func (m *mockOauthProvider) GetName() string { return "" } +func (m *mockOauthProvider) GetTitle() string { return "" } +func (m *mockOauthProvider) GetIssuer() string { return "" } +func (m *mockOauthProvider) GetTokenEndpoint() string { return m.tokenEndpoint } +func (m *mockOauthProvider) GetMTLSTokenEndpoint() string { return "" } +func (m *mockOauthProvider) GetAuthorizationEndpoint() string { return "" } +func (m *mockOauthProvider) GetSupportedScopes() []string { return nil } +func (m *mockOauthProvider) GetSupportedGrantTypes() []string { return nil } +func (m *mockOauthProvider) GetSupportedTokenAuthMethods() []string { return nil } +func (m *mockOauthProvider) GetSupportedResponseMethod() []string { return nil } +func (m *mockOauthProvider) RegisterClient(cm oauth.ClientMetadata) (oauth.ClientMetadata, error) { + return nil, nil +} +func (m *mockOauthProvider) UnregisterClient(clientID, accessToken, registrationClientURI string, scopes []string, grantType string) error { + return nil +} +func (m *mockOauthProvider) Validate() error { return nil } +func (m *mockOauthProvider) GetConfig() corecfg.IDPConfig { return nil } +func (m *mockOauthProvider) GetMetadata() *oauth.AuthorizationServerMetadata { return nil } +func (m *mockOauthProvider) GetIDPResourceName() string { return "" } + +// mockIDPProvisioner is a minimal idp.Provisioner implementation for testing. +type mockIDPProvisioner struct { + isIDPCredential bool + provider oauth.Provider + credentialData provisioning.IDPCredentialData +} + +func (m *mockIDPProvisioner) IsIDPCredential() bool { return m.isIDPCredential } +func (m *mockIDPProvisioner) GetIDPProvider() oauth.Provider { return m.provider } +func (m *mockIDPProvisioner) GetIDPCredentialData() provisioning.IDPCredentialData { + return m.credentialData +} +func (m *mockIDPProvisioner) RegisterClient() error { return nil } +func (m *mockIDPProvisioner) UnregisterClient() error { return nil } +func (m *mockIDPProvisioner) GetAgentDetails() (map[string]string, error) { return nil, nil } +func (m *mockIDPProvisioner) Validate() error { return nil } + +func TestNewWebhookCredentialRequest(t *testing.T) { + t.Run("non-IDP credential", func(t *testing.T) { + c := &provCreds{ + id: "cred-id", + name: "cred-name", + managedApp: "my-app", + credType: "cred-type", + credAction: 1, + credData: map[string]interface{}{"a": "b"}, + credDetails: map[string]interface{}{"c": "d"}, + appDetails: map[string]interface{}{"e": "f"}, + credSchema: map[string]interface{}{"g": "h"}, + credProvSchema: map[string]interface{}{"i": "j"}, + credSchemaDetails: map[string]interface{}{"k": "l"}, + provisionMode: "mode", + days: 30, + idpProvisioner: &mockIDPProvisioner{isIDPCredential: false}, + } + + req := newWebhookCredentialRequest(webhookOperationProvision, c) + + assert.Equal(t, webhookOperationProvision, req.Operation) + assert.Equal(t, "cred-id", req.ID) + assert.Equal(t, "cred-name", req.Name) + assert.Equal(t, "my-app", req.ManagedApplicationName) + assert.Equal(t, "cred-type", req.CredentialType) + assert.Equal(t, 1, req.CredentialAction) + assert.Equal(t, c.credData, req.CredentialData) + assert.Equal(t, c.credDetails, req.CredentialDetails) + assert.Equal(t, c.appDetails, req.ApplicationDetails) + assert.Equal(t, c.credSchema, req.CredentialSchema) + assert.Equal(t, c.credProvSchema, req.CredentialProvisionSchema) + assert.Equal(t, c.credSchemaDetails, req.CredentialSchemaDetails) + assert.Equal(t, "mode", req.ProvisionMode) + assert.Equal(t, 30, req.ExpirationDays) + assert.Equal(t, "", req.IDPClientID) + assert.Equal(t, "", req.IDPTokenEndpoint) + }) + + t.Run("IDP credential", func(t *testing.T) { + c := &provCreds{ + id: "cred-id", + idpProvisioner: &mockIDPProvisioner{ + isIDPCredential: true, + provider: &mockOauthProvider{tokenEndpoint: "https://idp/token"}, + credentialData: &mockIDPCredentialData{clientID: "client-id"}, + }, + } + + req := newWebhookCredentialRequest(webhookOperationDeprovision, c) + + assert.Equal(t, "client-id", req.IDPClientID) + assert.Equal(t, "https://idp/token", req.IDPTokenEndpoint) + }) +} From 8c11d75e871457e71f53f9090178be96aa66c3e4 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 14 Sep 2026 14:57:21 -0700 Subject: [PATCH 11/12] do not overwrite webhook related keys in agent details --- pkg/agent/handler/webhook.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/agent/handler/webhook.go b/pkg/agent/handler/webhook.go index 2ad05c532..c9a6c274f 100644 --- a/pkg/agent/handler/webhook.go +++ b/pkg/agent/handler/webhook.go @@ -1,6 +1,8 @@ package handler import ( + "slices" + defs "github.com/Axway/agent-sdk/pkg/apic/definitions" "github.com/Axway/agent-sdk/pkg/util" ) @@ -27,6 +29,8 @@ const ( webhookStatusFailed = "failed" ) +var webhookReservedDetailKey = []string{webhookDispatchDetailKey, webhookStatusKey, webhookMessageKey} + // subResourceCarrier is satisfied by any apiserver resource instance type (ManagedApplication, AccessRequest, // Credential, ...) - matches the generic GetSubResource/SetSubResource pair they all get from ResourceMeta, // which is also what the unexported util.handler interface requires. @@ -74,7 +78,8 @@ func webhookDetailsValue(h subResourceCarrier, key string) string { // mirrorWebhookDetails copies the resource's x-webhook-details subresource into its x-agent-details, so // existing code (traceability lookups, etc.) that only knows how to read x-agent-details keeps working once // a provisioning webhook is configured - the webhook itself is scoped to write only x-webhook-details, not -// x-agent-details directly. Returns false if there is nothing to mirror. +// x-agent-details directly. Reserved keys (see webhookReservedDetailKey) are never copied over. Returns +// false if there is nothing to mirror. func mirrorWebhookDetails(h subResourceCarrier) bool { webhookDetails, ok := h.GetSubResource(defs.XWebhookDetails).(map[string]interface{}) if !ok || len(webhookDetails) == 0 { @@ -86,6 +91,9 @@ func mirrorWebhookDetails(h subResourceCarrier) bool { agentDetails = map[string]interface{}{} } for k, v := range webhookDetails { + if slices.Contains(webhookReservedDetailKey, k) { + continue + } agentDetails[k] = v } util.SetAgentDetails(h, agentDetails) From 84f4de4205d4c2c656258d627126fce7cd7303d0 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 14 Sep 2026 14:57:32 -0700 Subject: [PATCH 12/12] table driven tests --- pkg/agent/handler/webhook_test.go | 522 +++++++++++++++++++----------- 1 file changed, 333 insertions(+), 189 deletions(-) diff --git a/pkg/agent/handler/webhook_test.go b/pkg/agent/handler/webhook_test.go index 8a642fc1c..4fdde3579 100644 --- a/pkg/agent/handler/webhook_test.go +++ b/pkg/agent/handler/webhook_test.go @@ -1,6 +1,7 @@ package handler import ( + "slices" "testing" defs "github.com/Axway/agent-sdk/pkg/apic/definitions" @@ -52,123 +53,243 @@ func TestWebhookDispatchedFor(t *testing.T) { } func TestWebhookDispatchedOperation(t *testing.T) { - h := newMockSubResourceCarrier() - assert.Equal(t, "", webhookDispatchedOperation(h)) - - markWebhookDispatched(h, webhookOperationProvision) - assert.Equal(t, webhookOperationProvision, webhookDispatchedOperation(h)) + tests := []struct { + name string + marks []string + expected string + }{ + {name: "nothing dispatched yet", marks: nil, expected: ""}, + {name: "single dispatch recorded", marks: []string{webhookOperationProvision}, expected: webhookOperationProvision}, + {name: "later dispatch overwrites earlier one", marks: []string{webhookOperationProvision, webhookOperationDeprovision}, expected: webhookOperationDeprovision}, + } - markWebhookDispatched(h, webhookOperationDeprovision) - assert.Equal(t, webhookOperationDeprovision, webhookDispatchedOperation(h)) -} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newMockSubResourceCarrier() + for _, op := range tc.marks { + markWebhookDispatched(h, op) + } -func TestMarkWebhookDispatched(t *testing.T) { - h := newMockSubResourceCarrier() - markWebhookDispatched(h, webhookOperationProvision) + assert.Equal(t, tc.expected, webhookDispatchedOperation(h)) - details, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, webhookOperationProvision, details[webhookDispatchDetailKey]) + details, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) + if tc.expected == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + assert.Equal(t, tc.expected, details[webhookDispatchDetailKey]) + } + }) + } } func TestWebhookDetailsValue(t *testing.T) { - h := newMockSubResourceCarrier() - assert.Equal(t, "", webhookDetailsValue(h, webhookStatusKey)) - - h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ - webhookStatusKey: webhookStatusSuccess, - webhookMessageKey: "all good", - }) - assert.Equal(t, webhookStatusSuccess, webhookDetailsValue(h, webhookStatusKey)) - assert.Equal(t, "all good", webhookDetailsValue(h, webhookMessageKey)) - assert.Equal(t, "", webhookDetailsValue(h, "missing-key")) - - // non-string values are ignored - h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{webhookStatusKey: 1}) - assert.Equal(t, "", webhookDetailsValue(h, webhookStatusKey)) + tests := []struct { + name string + webhookDetails map[string]interface{} + key string + expected string + }{ + {name: "no webhook details subresource", key: webhookStatusKey, expected: ""}, + { + name: "reads status", + webhookDetails: map[string]interface{}{webhookStatusKey: webhookStatusSuccess, webhookMessageKey: "all good"}, + key: webhookStatusKey, + expected: webhookStatusSuccess, + }, + { + name: "reads message", + webhookDetails: map[string]interface{}{webhookStatusKey: webhookStatusSuccess, webhookMessageKey: "all good"}, + key: webhookMessageKey, + expected: "all good", + }, + { + name: "missing key", + webhookDetails: map[string]interface{}{webhookStatusKey: webhookStatusSuccess}, + key: "missing-key", + expected: "", + }, + { + name: "non-string value is ignored", + webhookDetails: map[string]interface{}{webhookStatusKey: 1}, + key: webhookStatusKey, + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newMockSubResourceCarrier() + if tc.webhookDetails != nil { + h.SetSubResource(defs.XWebhookDetails, tc.webhookDetails) + } + assert.Equal(t, tc.expected, webhookDetailsValue(h, tc.key)) + }) + } } func TestMirrorWebhookDetails(t *testing.T) { - t.Run("no webhook details to mirror", func(t *testing.T) { - h := newMockSubResourceCarrier() - assert.False(t, mirrorWebhookDetails(h)) - }) - - t.Run("empty webhook details map", func(t *testing.T) { - h := newMockSubResourceCarrier() - h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{}) - assert.False(t, mirrorWebhookDetails(h)) - }) - - t.Run("mirrors into new agent details", func(t *testing.T) { - h := newMockSubResourceCarrier() - h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ - webhookStatusKey: webhookStatusSuccess, - }) + tests := []struct { + name string + existingAgentDetails map[string]interface{} + hasWebhookDetails bool + webhookDetails map[string]interface{} + expectedReturn bool + expectedAgentDetails map[string]interface{} + }{ + { + name: "no webhook details to mirror", + hasWebhookDetails: false, + expectedReturn: false, + }, + { + name: "empty webhook details map", + hasWebhookDetails: true, + webhookDetails: map[string]interface{}{}, + expectedReturn: false, + }, + { + name: "mirrors into new agent details", + hasWebhookDetails: true, + webhookDetails: map[string]interface{}{"clientId": "abc123"}, + expectedReturn: true, + expectedAgentDetails: map[string]interface{}{"clientId": "abc123"}, + }, + { + name: "merges into existing agent details without dropping other keys", + existingAgentDetails: map[string]interface{}{"existing": "value"}, + hasWebhookDetails: true, + webhookDetails: map[string]interface{}{"clientId": "abc123"}, + expectedReturn: true, + expectedAgentDetails: map[string]interface{}{"existing": "value", "clientId": "abc123"}, + }, + { + name: "does not let a webhook payload clobber the agent's reserved keys", + existingAgentDetails: map[string]interface{}{webhookDispatchDetailKey: "update"}, + hasWebhookDetails: true, + webhookDetails: map[string]interface{}{ + webhookStatusKey: webhookStatusSuccess, + webhookMessageKey: "all good", + webhookDispatchDetailKey: "provision", + "clientId": "abc123", + }, + expectedReturn: true, + expectedAgentDetails: map[string]interface{}{ + webhookDispatchDetailKey: "update", + "clientId": "abc123", + }, + }, + } - assert.True(t, mirrorWebhookDetails(h)) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newMockSubResourceCarrier() + if tc.existingAgentDetails != nil { + h.SetSubResource(defs.XAgentDetails, tc.existingAgentDetails) + } + if tc.hasWebhookDetails { + h.SetSubResource(defs.XWebhookDetails, tc.webhookDetails) + } - agentDetails, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, webhookStatusSuccess, agentDetails[webhookStatusKey]) - }) + assert.Equal(t, tc.expectedReturn, mirrorWebhookDetails(h)) - t.Run("merges into existing agent details without dropping other keys", func(t *testing.T) { - h := newMockSubResourceCarrier() - h.SetSubResource(defs.XAgentDetails, map[string]interface{}{"existing": "value"}) - h.SetSubResource(defs.XWebhookDetails, map[string]interface{}{ - webhookStatusKey: webhookStatusFailed, + if tc.expectedAgentDetails != nil { + agentDetails, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, tc.expectedAgentDetails, agentDetails) + } }) + } +} - assert.True(t, mirrorWebhookDetails(h)) +func TestWebhookReservedDetailKey(t *testing.T) { + tests := []struct { + name string + key string + reserved bool + }{ + {name: "dispatch detail key is reserved", key: webhookDispatchDetailKey, reserved: true}, + {name: "status key is reserved", key: webhookStatusKey, reserved: true}, + {name: "message key is reserved", key: webhookMessageKey, reserved: true}, + {name: "business data key is not reserved", key: "clientId", reserved: false}, + {name: "similarly named key is not reserved", key: "status_details", reserved: false}, + } - agentDetails, ok := h.GetSubResource(defs.XAgentDetails).(map[string]interface{}) - assert.True(t, ok) - assert.Equal(t, "value", agentDetails["existing"]) - assert.Equal(t, webhookStatusFailed, agentDetails[webhookStatusKey]) - }) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.reserved, slices.Contains(webhookReservedDetailKey, tc.key)) + }) + } } func TestNewWebhookApplicationRequest(t *testing.T) { - app := provManagedApp{ - id: "app-id", - managedAppName: "my-app", - teamName: "my-team", - consumerOrgID: "org-id", - data: map[string]interface{}{"foo": "bar"}, + tests := []struct { + name string + app provManagedApp + expected webhookApplicationRequest + }{ + { + name: "builds request from managed app", + app: provManagedApp{ + id: "app-id", + managedAppName: "my-app", + teamName: "my-team", + consumerOrgID: "org-id", + data: map[string]interface{}{"foo": "bar"}, + }, + expected: webhookApplicationRequest{ + Operation: webhookOperationProvision, + ID: "app-id", + ManagedApplicationName: "my-app", + TeamName: "my-team", + ConsumerOrgID: "org-id", + AgentDetails: map[string]interface{}{"foo": "bar"}, + }, + }, } - req := newWebhookApplicationRequest(webhookOperationProvision, app) - - assert.Equal(t, webhookOperationProvision, req.Operation) - assert.Equal(t, "app-id", req.ID) - assert.Equal(t, "my-app", req.ManagedApplicationName) - assert.Equal(t, "my-team", req.TeamName) - assert.Equal(t, "org-id", req.ConsumerOrgID) - assert.Equal(t, app.data, req.AgentDetails) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, newWebhookApplicationRequest(webhookOperationProvision, tc.app)) + }) + } } func TestNewWebhookApplicationProfileRequest(t *testing.T) { - profile := provManagedAppProfile{ - id: "profile-id", - managedAppName: "my-app", - profileDefinition: "my-profile-def", - teamName: "my-team", - consumerOrgID: "org-id", - attributes: map[string]interface{}{"attr": "val"}, - data: map[string]interface{}{"foo": "bar"}, + tests := []struct { + name string + profile provManagedAppProfile + expected webhookApplicationProfileRequest + }{ + { + name: "builds request from managed app profile", + profile: provManagedAppProfile{ + id: "profile-id", + managedAppName: "my-app", + profileDefinition: "my-profile-def", + teamName: "my-team", + consumerOrgID: "org-id", + attributes: map[string]interface{}{"attr": "val"}, + data: map[string]interface{}{"foo": "bar"}, + }, + expected: webhookApplicationProfileRequest{ + Operation: webhookOperationDeprovision, + ID: "profile-id", + ManagedApplicationName: "my-app", + ApplicationProfileDefinition: "my-profile-def", + TeamName: "my-team", + ConsumerOrgID: "org-id", + Attributes: map[string]interface{}{"attr": "val"}, + ApplicationDetails: map[string]interface{}{"foo": "bar"}, + }, + }, } - req := newWebhookApplicationProfileRequest(webhookOperationDeprovision, profile) - - assert.Equal(t, webhookOperationDeprovision, req.Operation) - assert.Equal(t, "profile-id", req.ID) - assert.Equal(t, "my-app", req.ManagedApplicationName) - assert.Equal(t, "my-profile-def", req.ApplicationProfileDefinition) - assert.Equal(t, "my-team", req.TeamName) - assert.Equal(t, "org-id", req.ConsumerOrgID) - assert.Equal(t, profile.attributes, req.Attributes) - assert.Equal(t, profile.data, req.ApplicationDetails) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, newWebhookApplicationProfileRequest(webhookOperationDeprovision, tc.profile)) + }) + } } // mockQuota is a minimal provisioning.Quota implementation for testing. @@ -183,52 +304,61 @@ func (q *mockQuota) GetLimit() int64 { return q.limit } func (q *mockQuota) GetPlanName() string { return "" } func TestNewWebhookAccessRequest(t *testing.T) { - t.Run("without quota, not transferring", func(t *testing.T) { - r := provAccReq{ - id: "ar-id", - managedApp: "my-app", - requestData: map[string]interface{}{"req": "data"}, - provData: "prov-data", - accessDetails: map[string]interface{}{"access": "details"}, - refAccessDetails: map[string]interface{}{"ref": "details"}, - appDetails: map[string]interface{}{"app": "details"}, - instanceDetails: map[string]interface{}{"instance": "details"}, - } - - req := newWebhookAccessRequest(webhookOperationProvision, r) - - assert.Equal(t, webhookOperationProvision, req.Operation) - assert.Equal(t, "ar-id", req.ID) - assert.Equal(t, "", req.ReferencedID) - assert.Equal(t, "my-app", req.ManagedApplicationName) - assert.False(t, req.IsTransferring) - assert.Equal(t, r.requestData, req.RequestData) - assert.Equal(t, r.provData, req.ProvisioningData) - assert.Equal(t, r.accessDetails, req.AccessDetails) - assert.Equal(t, r.refAccessDetails, req.ReferencedAccessDetails) - assert.Equal(t, r.appDetails, req.ApplicationDetails) - assert.Equal(t, r.instanceDetails, req.InstanceDetails) - assert.Nil(t, req.Quota) - }) - - t.Run("transferring when refID set", func(t *testing.T) { - r := provAccReq{id: "ar-id", refID: "ref-id"} - req := newWebhookAccessRequest(webhookOperationProvision, r) - assert.Equal(t, "ref-id", req.ReferencedID) - assert.True(t, req.IsTransferring) - }) - - t.Run("with quota", func(t *testing.T) { - r := provAccReq{ - id: "ar-id", - quota: &mockQuota{limit: 100, interval: "daily"}, - } - req := newWebhookAccessRequest(webhookOperationProvision, r) - if assert.NotNil(t, req.Quota) { - assert.Equal(t, int64(100), req.Quota.Limit) - assert.Equal(t, "daily", req.Quota.Interval) - } - }) + tests := []struct { + name string + request provAccReq + expected webhookAccessRequest + }{ + { + name: "without quota, not transferring", + request: provAccReq{ + id: "ar-id", + managedApp: "my-app", + requestData: map[string]interface{}{"req": "data"}, + provData: "prov-data", + accessDetails: map[string]interface{}{"access": "details"}, + refAccessDetails: map[string]interface{}{"ref": "details"}, + appDetails: map[string]interface{}{"app": "details"}, + instanceDetails: map[string]interface{}{"instance": "details"}, + }, + expected: webhookAccessRequest{ + Operation: webhookOperationProvision, + ID: "ar-id", + ManagedApplicationName: "my-app", + RequestData: map[string]interface{}{"req": "data"}, + ProvisioningData: "prov-data", + AccessDetails: map[string]interface{}{"access": "details"}, + ReferencedAccessDetails: map[string]interface{}{"ref": "details"}, + ApplicationDetails: map[string]interface{}{"app": "details"}, + InstanceDetails: map[string]interface{}{"instance": "details"}, + }, + }, + { + name: "transferring when refID set", + request: provAccReq{id: "ar-id", refID: "ref-id"}, + expected: webhookAccessRequest{ + Operation: webhookOperationProvision, + ID: "ar-id", + ReferencedID: "ref-id", + IsTransferring: true, + }, + }, + { + name: "with quota", + request: provAccReq{id: "ar-id", quota: &mockQuota{limit: 100, interval: "daily"}}, + expected: webhookAccessRequest{ + Operation: webhookOperationProvision, + ID: "ar-id", + Quota: &webhookQuota{Limit: 100, Interval: "daily"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, newWebhookAccessRequest(webhookOperationProvision, tc.request)) + }) + } } // mockIDPCredentialData is a minimal provisioning.IDPCredentialData implementation for testing. @@ -296,57 +426,71 @@ func (m *mockIDPProvisioner) GetAgentDetails() (map[string]string, error) { retu func (m *mockIDPProvisioner) Validate() error { return nil } func TestNewWebhookCredentialRequest(t *testing.T) { - t.Run("non-IDP credential", func(t *testing.T) { - c := &provCreds{ - id: "cred-id", - name: "cred-name", - managedApp: "my-app", - credType: "cred-type", - credAction: 1, - credData: map[string]interface{}{"a": "b"}, - credDetails: map[string]interface{}{"c": "d"}, - appDetails: map[string]interface{}{"e": "f"}, - credSchema: map[string]interface{}{"g": "h"}, - credProvSchema: map[string]interface{}{"i": "j"}, - credSchemaDetails: map[string]interface{}{"k": "l"}, - provisionMode: "mode", - days: 30, - idpProvisioner: &mockIDPProvisioner{isIDPCredential: false}, - } - - req := newWebhookCredentialRequest(webhookOperationProvision, c) - - assert.Equal(t, webhookOperationProvision, req.Operation) - assert.Equal(t, "cred-id", req.ID) - assert.Equal(t, "cred-name", req.Name) - assert.Equal(t, "my-app", req.ManagedApplicationName) - assert.Equal(t, "cred-type", req.CredentialType) - assert.Equal(t, 1, req.CredentialAction) - assert.Equal(t, c.credData, req.CredentialData) - assert.Equal(t, c.credDetails, req.CredentialDetails) - assert.Equal(t, c.appDetails, req.ApplicationDetails) - assert.Equal(t, c.credSchema, req.CredentialSchema) - assert.Equal(t, c.credProvSchema, req.CredentialProvisionSchema) - assert.Equal(t, c.credSchemaDetails, req.CredentialSchemaDetails) - assert.Equal(t, "mode", req.ProvisionMode) - assert.Equal(t, 30, req.ExpirationDays) - assert.Equal(t, "", req.IDPClientID) - assert.Equal(t, "", req.IDPTokenEndpoint) - }) - - t.Run("IDP credential", func(t *testing.T) { - c := &provCreds{ - id: "cred-id", - idpProvisioner: &mockIDPProvisioner{ - isIDPCredential: true, - provider: &mockOauthProvider{tokenEndpoint: "https://idp/token"}, - credentialData: &mockIDPCredentialData{clientID: "client-id"}, + tests := []struct { + name string + operation string + creds *provCreds + expected webhookCredentialRequest + }{ + { + name: "non-IDP credential", + operation: webhookOperationProvision, + creds: &provCreds{ + id: "cred-id", + name: "cred-name", + managedApp: "my-app", + credType: "cred-type", + credAction: 1, + credData: map[string]interface{}{"a": "b"}, + credDetails: map[string]interface{}{"c": "d"}, + appDetails: map[string]interface{}{"e": "f"}, + credSchema: map[string]interface{}{"g": "h"}, + credProvSchema: map[string]interface{}{"i": "j"}, + credSchemaDetails: map[string]interface{}{"k": "l"}, + provisionMode: "mode", + days: 30, + idpProvisioner: &mockIDPProvisioner{isIDPCredential: false}, }, - } - - req := newWebhookCredentialRequest(webhookOperationDeprovision, c) + expected: webhookCredentialRequest{ + Operation: webhookOperationProvision, + ID: "cred-id", + Name: "cred-name", + ManagedApplicationName: "my-app", + CredentialType: "cred-type", + CredentialAction: 1, + CredentialData: map[string]interface{}{"a": "b"}, + CredentialDetails: map[string]interface{}{"c": "d"}, + ApplicationDetails: map[string]interface{}{"e": "f"}, + CredentialSchema: map[string]interface{}{"g": "h"}, + CredentialProvisionSchema: map[string]interface{}{"i": "j"}, + CredentialSchemaDetails: map[string]interface{}{"k": "l"}, + ProvisionMode: "mode", + ExpirationDays: 30, + }, + }, + { + name: "IDP credential", + operation: webhookOperationDeprovision, + creds: &provCreds{ + id: "cred-id", + idpProvisioner: &mockIDPProvisioner{ + isIDPCredential: true, + provider: &mockOauthProvider{tokenEndpoint: "https://idp/token"}, + credentialData: &mockIDPCredentialData{clientID: "client-id"}, + }, + }, + expected: webhookCredentialRequest{ + Operation: webhookOperationDeprovision, + ID: "cred-id", + IDPClientID: "client-id", + IDPTokenEndpoint: "https://idp/token", + }, + }, + } - assert.Equal(t, "client-id", req.IDPClientID) - assert.Equal(t, "https://idp/token", req.IDPTokenEndpoint) - }) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, newWebhookCredentialRequest(tc.operation, tc.creds)) + }) + } }