From 1650d9a2c8e7a93679d8c016329bebea84787f9d Mon Sep 17 00:00:00 2001 From: Pavan <25031267+Pavan-SAP@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:24:45 +0100 Subject: [PATCH] [Feat] Server: Configurable subscription domain supported Allow to configure the returned subscription URL on tenant level through subscription request. This can be done via `subscriptionDomain` field in `subscriptionParams`. This enables customers to specify which domain should be returned during subscription (e.g. for the subscription URL), in case there are multiple domains configured in the system. --- cmd/server/internal/handler.go | 139 +++++++++++++----- cmd/server/internal/handler_test.go | 217 +++++++++++++++++++++++++++- 2 files changed, 321 insertions(+), 35 deletions(-) diff --git a/cmd/server/internal/handler.go b/cmd/server/internal/handler.go index 8b05030c..af361760 100644 --- a/cmd/server/internal/handler.go +++ b/cmd/server/internal/handler.go @@ -57,6 +57,7 @@ const ( TenantNotFound = "tenant not found" ) +const SubscriptionDomain = "subscription domain" const ErrorOccurred = "Error occurred " const InvalidRequestMethod = "invalid request method" const AuthorizationCheckFailed = "authorization check failed" @@ -81,6 +82,8 @@ const ( type RequestInfo struct { // One of "SMS" or "SaaS" subscriptionType subscriptionType + // subscription domain from the subscription request, used for constructing the subscription URL. If not present, existing fallbacks will be used + subscriptionDomain string // payload Details payload *payloadDetails // header details @@ -129,12 +132,12 @@ type callbackResponse struct { type SaaSCallbackResponse struct { callbackResponse `json:",inline"` - SubscriptionUrl string `json:"subscriptionUrl"` + SubscriptionUrl string `json:"subscriptionUrl,omitempty"` } type SmsCallbackResponse struct { callbackResponse `json:",inline"` - ApplicationUrl string `json:"applicationUrl"` + ApplicationUrl string `json:"applicationUrl,omitempty"` } type CallbackReqInfo struct { @@ -173,6 +176,12 @@ func (s *SubscriptionHandler) CreateTenant(reqInfo *RequestInfo) *Result { return &Result{Tenant: nil, Message: err.Error()} } + appUrl, err := s.getAppURL(reqInfo.subscriptionDomain, reqInfo.payload.subdomain, ca) + if err != nil { + util.LogError(err, ErrorOccurred, TenantProvisioning, ca, nil) + return &Result{Tenant: nil, Message: "Error constructing subscription URL: " + err.Error()} + } + // Check if A CRO for CAPTenant already exists tenant := s.getTenantByBtpAppIdentifier(ca.Spec.GlobalAccountId, reqInfo.payload.appName, reqInfo.payload.tenantId, ca.Namespace, TenantProvisioning).Tenant @@ -195,26 +204,26 @@ func (s *SubscriptionHandler) CreateTenant(reqInfo *RequestInfo) *Result { if tenant != nil { tenantIn := tenantInfo{tenantId: reqInfo.payload.tenantId, tenantSubDomain: reqInfo.payload.subdomain} callbackReqInfo := s.getCallbackReqInfo(reqInfo.subscriptionType, reqInfo.headerDetails.callbackInfo, saasData, smsData) - s.initializeCallback(tenant.Name, ca, callbackReqInfo, tenantIn, true) - } - - // Tenant created/exists - message := func(isCreated, isUpdated bool) string { - if isCreated { - return ResourceCreated - } else if isUpdated { - return ResourceUpdated - } else { - return ResourceFound - } + s.initializeCallback(appUrl, tenant.Name, ca, callbackReqInfo, tenantIn, true) } if created { - util.LogInfo("Tenant successfully created", TenantProvisioning, ca, tenant, "message", message(created, updated)) + util.LogInfo("Tenant successfully created", TenantProvisioning, ca, tenant, "message", getMessage(created, updated)) } else if updated { - util.LogInfo("Tenant successfully updated", TenantProvisioning, ca, tenant, "message", message(created, updated)) + util.LogInfo("Tenant successfully updated", TenantProvisioning, ca, tenant, "message", getMessage(created, updated)) + } + return &Result{Tenant: tenant, Message: getMessage(created, updated)} +} + +func getMessage(isCreated, isUpdated bool) string { + // Tenant created/exists + if isCreated { + return ResourceCreated + } else if isUpdated { + return ResourceUpdated + } else { + return ResourceFound } - return &Result{Tenant: tenant, Message: message(created, updated)} } func (s *SubscriptionHandler) createTenant(reqInfo *RequestInfo, ca *v1alpha1.CAPApplication) (tenant *v1alpha1.CAPTenant, err error) { @@ -495,7 +504,7 @@ func (s *SubscriptionHandler) DeleteTenant(reqInfo *RequestInfo) *Result { tenantIn := tenantInfo{tenantId: reqInfo.payload.tenantId, tenantSubDomain: reqInfo.payload.subdomain} callbackReqInfo := s.getCallbackReqInfo(reqInfo.subscriptionType, reqInfo.headerDetails.callbackInfo, saasData, smsData) - s.initializeCallback(tenant.Name, ca, callbackReqInfo, tenantIn, false) + s.initializeCallback("", tenant.Name, ca, callbackReqInfo, tenantIn, false) return &Result{Tenant: tenant, Message: ResourceDeleted} } @@ -591,20 +600,12 @@ func (s *SubscriptionHandler) checkCertIssuerAndSubject(xForwardedClientCert str return nil } -func (s *SubscriptionHandler) initializeCallback(tenantName string, ca *v1alpha1.CAPApplication, callbackReqInfo *CallbackReqInfo, tenantIn tenantInfo, isProvisioning bool) { - subscriptionDomain := ca.Annotations[AnnotationSubscriptionDomain] - if subscriptionDomain == "" { - subscriptionDomain = s.getPrimaryDomain(ca) - } - - appUrl := "https://" + tenantIn.tenantSubDomain + "." + subscriptionDomain - asyncCallbackPath := callbackReqInfo.CallbackPath - util.LogInfo("Callback initialized", TenantProvisioning, ca, nil, "subscription URL", appUrl, "async callback path", asyncCallbackPath, "tenantName", tenantName) - +func (s *SubscriptionHandler) initializeCallback(appUrl, tenantName string, ca *v1alpha1.CAPApplication, callbackReqInfo *CallbackReqInfo, tenantIn tenantInfo, isProvisioning bool) { step := TenantProvisioning if !isProvisioning { step = TenantDeprovisioning } + util.LogInfo("Callback initialized", step, ca, nil, "subscription URL", appUrl, "async callback path", callbackReqInfo.CallbackPath, "tenantName", tenantName) go func() { // create a context for tenant checks and outgoing requests @@ -635,10 +636,65 @@ func (s *SubscriptionHandler) initializeCallback(tenantName string, ca *v1alpha1 } else { additionalOutput = nil } - s.handleAsyncCallback(ctx, callbackReqInfo, status, asyncCallbackPath, appUrl, additionalOutput, isProvisioning) + + s.handleAsyncCallback(ctx, callbackReqInfo, status, callbackReqInfo.CallbackPath, appUrl, additionalOutput, isProvisioning) }() +} - util.LogInfo("Waiting for async callback after checks...", step, ca, nil, "tenantName", tenantName) +func (s *SubscriptionHandler) getAppURL(payloadSubscriptionDomain, tenantSubdomain string, ca *v1alpha1.CAPApplication) (string, error) { + needsValidaton := true + var subscriptionDomain string + // Check if subscription domain is provided in the request payload. + if payloadSubscriptionDomain != "" { + subscriptionDomain = payloadSubscriptionDomain + util.LogInfo("Using subscription domain from request payload", TenantProvisioning, ca, nil, SubscriptionDomain, subscriptionDomain) + } else { + // Fallback: + // First, check if subscription domain is provided in the CAPApplication annotation. If not, fallback to calculating the primary domain from the CAPApplication domain refs and use that as the subscription domain. + subscriptionDomain = ca.Annotations[AnnotationSubscriptionDomain] + if subscriptionDomain == "" { + subscriptionDomain = s.getPrimaryDomain(ca) + needsValidaton = false + util.LogInfo("Using subscription domain from fallback 'primary' calculation", TenantProvisioning, ca, nil, SubscriptionDomain, subscriptionDomain) + } else { + util.LogInfo("Using subscription domain from CAPApplication annotation", TenantProvisioning, ca, nil, SubscriptionDomain, subscriptionDomain) + } + } + + if needsValidaton { + err := s.validateDomain(subscriptionDomain, ca.Namespace) + if err != nil { + return "", err + } + } + + return "https://" + tenantSubdomain + "." + subscriptionDomain, nil +} + +func (s *SubscriptionHandler) validateDomain(domain, namespace string) error { + // First check for Domains in the apps namespace + domainsList, err := s.Clientset.SmeV1alpha1().Domains(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return err + } + for _, d := range domainsList.Items { + if d.Spec.Domain == domain { + return nil + } + } + + // Check for ClusterDomains if not found in the namespace + clusterDomainsList, err := s.Clientset.SmeV1alpha1().ClusterDomains(metav1.NamespaceAll).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return err + } + for _, cd := range clusterDomainsList.Items { + if cd.Spec.Domain == domain { + return nil + } + } + + return fmt.Errorf("domain %s not found in Domains or ClusterDomains", domain) } func (s *SubscriptionHandler) getPrimaryDomain(ca *v1alpha1.CAPApplication) string { @@ -899,6 +955,7 @@ func (s *SubscriptionHandler) handleAsyncCallback(ctx context.Context, callbackR Message: checkMatch(status, checkMatch(isProvisioning, ProvisioningSucceededMessage, DeprovisioningSucceededMessage), checkMatch(isProvisioning, ProvisioningFailedMessage, DeprovisioningFailedMessage)), AdditionalOutput: additionalOutput, } + switch callbackReqInfo.SubscriptionType { case SMS: payload, _ = json.Marshal(&SmsCallbackResponse{ @@ -983,7 +1040,7 @@ func (s *SubscriptionHandler) HandleSMSRequest(w http.ResponseWriter, req *http. } func ProcessRequest(req *http.Request, subscriptionType subscriptionType) (*RequestInfo, error) { - var subscriptionGUID, tenantId, subdomain, globalAccountId, providerSubaccountId, appName string + var subscriptionGUID, tenantId, subdomain, globalAccountId, providerSubaccountId, appName, subscriptionDomain string var jsonPayload map[string]any if !(req.Method == http.MethodDelete && subscriptionType == SMS) { @@ -1010,6 +1067,7 @@ func ProcessRequest(req *http.Request, subscriptionType subscriptionType) (*Requ rootApp := jsonPayload["rootApplication"].(map[string]any) providerSubaccountId = rootApp["providerSubaccountId"].(string) appName = rootApp["appName"].(string) + subscriptionDomain = getSubscriptionDomain(rootApp) case http.MethodDelete: // get paramater from URL subscriptionGUID = req.URL.Query().Get("subscriptionGUID") @@ -1032,6 +1090,7 @@ func ProcessRequest(req *http.Request, subscriptionType subscriptionType) (*Requ globalAccountId = jsonPayload["globalAccountGUID"].(string) providerSubaccountId = jsonPayload["providerSubaccountId"].(string) appName = jsonPayload["subscriptionAppName"].(string) + subscriptionDomain = getSubscriptionDomain(jsonPayload) } payload := &payloadDetails{ @@ -1045,12 +1104,24 @@ func ProcessRequest(req *http.Request, subscriptionType subscriptionType) (*Requ raw: &jsonPayload, } return &RequestInfo{ - subscriptionType: subscriptionType, - payload: payload, - headerDetails: &headerDetails, + subscriptionType: subscriptionType, + subscriptionDomain: subscriptionDomain, + payload: payload, + headerDetails: &headerDetails, }, nil } +func getSubscriptionDomain(payload map[string]any) string { + if subscriptionParams, ok := payload["subscriptionParams"]; ok { + if subscriptionParamsMap, ok := subscriptionParams.(map[string]any); ok { + if subscriptionDomain, ok := subscriptionParamsMap["subscriptionDomain"]; ok { + return subscriptionDomain.(string) + } + } + } + return "" +} + func NewSubscriptionHandler(clientset versioned.Interface, kubeClienset kubernetes.Interface) *SubscriptionHandler { return &SubscriptionHandler{Clientset: clientset, KubeClienset: kubeClienset, httpClientGenerator: &httpClientGeneratorImpl{}} } diff --git a/cmd/server/internal/handler_test.go b/cmd/server/internal/handler_test.go index 51a364be..8f9ea34d 100644 --- a/cmd/server/internal/handler_test.go +++ b/cmd/server/internal/handler_test.go @@ -399,7 +399,7 @@ func Test_provisioning(t *testing.T) { { name: "Provisioning Request valid (invalid clusterdomains)", method: http.MethodPut, - body: `{"subscriptionAppName":"` + appName + `","globalAccountGUID":"` + globalAccountId + `","providerSubaccountId":"` + providerSubaccountId + `","subscriptionGUID":"` + subscriptionGUID + `","subscribedTenantId":"` + tenantId + `","subscribedSubdomain":"` + subDomain + `"}`, + body: `{"subscriptionAppName":"` + appName + `","globalAccountGUID":"` + globalAccountId + `","providerSubaccountId":"` + providerSubaccountId + `","subscriptionGUID":"` + subscriptionGUID + `","subscribedTenantId":"` + tenantId + `","subscribedSubdomain":"` + subDomain + `","subscriptionParams":""}`, createCROs: true, invalidClusterDomain: true, expectedStatusCode: http.StatusAccepted, @@ -478,6 +478,40 @@ func Test_provisioning(t *testing.T) { Message: ResourceFound, }, }, + { + name: "Provisioning with subscriptionDomain in payload matching Domain", + method: http.MethodPut, + body: `{"subscriptionAppName":"` + appName + `","globalAccountGUID":"` + globalAccountId + `","providerSubaccountId":"` + providerSubaccountId + `","subscriptionGUID":"` + subscriptionGUID + `","subscribedTenantId":"` + tenantId + `","subscribedSubdomain":"` + subDomain + `","subscriptionParams":{"subscriptionDomain":"auth.service.local"}}`, + createCROs: true, + existingDomain: true, + expectedStatusCode: http.StatusAccepted, + expectedResponse: Result{ + Message: ResourceCreated, + }, + }, + { + name: "Provisioning with subscriptionDomain in payload not matching any domain", + method: http.MethodPut, + body: `{"subscriptionAppName":"` + appName + `","globalAccountGUID":"` + globalAccountId + `","providerSubaccountId":"` + providerSubaccountId + `","subscriptionGUID":"` + subscriptionGUID + `","subscribedTenantId":"` + tenantId + `","subscribedSubdomain":"` + subDomain + `","subscriptionParams":{"subscriptionDomain":"unknown.domain.com"}}`, + createCROs: true, + existingDomain: true, + expectedStatusCode: http.StatusNotAcceptable, + expectedResponse: Result{ + Message: "Error constructing subscription URL: domain unknown.domain.com not found in Domains or ClusterDomains", + }, + }, + { + name: "Provisioning with empty subscriptionParams (no subscriptionDomain)", + method: http.MethodPut, + body: `{"subscriptionAppName":"` + appName + `","globalAccountGUID":"` + globalAccountId + `","providerSubaccountId":"` + providerSubaccountId + `","subscriptionGUID":"` + subscriptionGUID + `","subscribedTenantId":"` + tenantId + `","subscribedSubdomain":"` + subDomain + `","subscriptionParams":{}}`, + createCROs: true, + existingDomain: true, + + expectedStatusCode: http.StatusAccepted, + expectedResponse: Result{ + Message: ResourceCreated, + }, + }, } for _, testData := range tests { @@ -715,6 +749,24 @@ func Test_sms_provisioning(t *testing.T) { Message: ResourceUpdated, }, }, + { + name: "SMS provisioning with subscriptionDomain in payload matching Domain", + method: http.MethodPut, + body: `{"rootApplication":{"appName":"` + appName + `","providerSubaccountId":"` + providerSubaccountId + `","commercialAppName":"` + appName + `","subscriptionParams":{"subscriptionDomain":"auth.service.local"}},"subscriber":{"subscriptionGUID":"` + subscriptionGUID + `","app_tid":"` + tenantId + `","globalAccountId":"` + globalAccountId + `","subaccountSubdomain":"` + subDomain + `"}}`, + createCROs: true, + existingDomain: true, + expectedStatusCode: http.StatusAccepted, + expectedResponse: Result{Message: ResourceCreated}, + }, + { + name: "SMS provisioning with subscriptionDomain in payload not matching any domain", + method: http.MethodPut, + body: `{"rootApplication":{"appName":"` + appName + `","providerSubaccountId":"` + providerSubaccountId + `","commercialAppName":"` + appName + `","subscriptionParams":{"subscriptionDomain":"unknown.domain.com"}},"subscriber":{"subscriptionGUID":"` + subscriptionGUID + `","app_tid":"` + tenantId + `","globalAccountId":"` + globalAccountId + `","subaccountSubdomain":"` + subDomain + `"}}`, + createCROs: true, + existingDomain: true, + expectedStatusCode: http.StatusNotAcceptable, + expectedResponse: Result{Message: "Error constructing subscription URL: domain unknown.domain.com not found in Domains or ClusterDomains"}, + }, } // Create and encode the client certificate once before all tests are executed @@ -1328,6 +1380,169 @@ func TestMultiXSUAA(t *testing.T) { }) } +func TestAppURL(t *testing.T) { + tests := []struct { + name string + payloadSubscriptionDomain string + tenantSubdomain string + caAnnotations map[string]string + domainRefs []v1alpha1.DomainRef + createDomain bool + createClusterDomain bool + expectedURL string + expectError bool + }{ + { + name: "subscription domain from payload with matching Domain", + payloadSubscriptionDomain: "auth.service.local", + tenantSubdomain: subDomain, + createDomain: true, + expectedURL: "https://" + subDomain + ".auth.service.local", + }, + { + name: "subscription domain from payload with matching ClusterDomain", + payloadSubscriptionDomain: "external.service.sap", + tenantSubdomain: subDomain, + createClusterDomain: true, + expectedURL: "https://" + subDomain + ".external.service.sap", + }, + { + name: "subscription domain from payload not found in any domain resource", + payloadSubscriptionDomain: "unknown.domain.com", + tenantSubdomain: subDomain, + expectError: true, + }, + { + name: "fallback to annotation subscription domain with matching Domain", + payloadSubscriptionDomain: "", + tenantSubdomain: subDomain, + caAnnotations: map[string]string{AnnotationSubscriptionDomain: "auth.service.local"}, + createDomain: true, + expectedURL: "https://" + subDomain + ".auth.service.local", + }, + { + name: "fallback to annotation subscription domain not found in any domain", + payloadSubscriptionDomain: "", + tenantSubdomain: subDomain, + caAnnotations: map[string]string{AnnotationSubscriptionDomain: "unknown.domain.com"}, + expectError: true, + }, + { + name: "fallback to primary domain calculation (Domain ref)", + payloadSubscriptionDomain: "", + tenantSubdomain: subDomain, + domainRefs: []v1alpha1.DomainRef{{Kind: "Domain", Name: "primary-domain"}}, + createDomain: true, + expectedURL: "https://" + subDomain + ".auth.service.local", + }, + { + name: "fallback to primary domain calculation (ClusterDomain ref)", + payloadSubscriptionDomain: "", + tenantSubdomain: subDomain, + domainRefs: []v1alpha1.DomainRef{{Kind: "ClusterDomain", Name: "external-domain"}}, + createClusterDomain: true, + expectedURL: "https://" + subDomain + ".external.service.sap", + }, + { + name: "fallback to primary domain with no domain refs", + payloadSubscriptionDomain: "", + tenantSubdomain: subDomain, + expectedURL: "https://" + subDomain + ".", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ca := createCA() + if tt.caAnnotations != nil { + ca.Annotations = tt.caAnnotations + } + if tt.domainRefs != nil { + ca.Spec.DomainRefs = tt.domainRefs + } + + runtimeObjs := []runtime.Object{ca} + if tt.createDomain { + runtimeObjs = append(runtimeObjs, createDomain()) + } + if tt.createClusterDomain { + runtimeObjs = append(runtimeObjs, createClusterDomain()) + } + + subHandler := setup(nil, createSecrets(), runtimeObjs...) + appURL, err := subHandler.getAppURL(tt.payloadSubscriptionDomain, tt.tenantSubdomain, ca) + + if tt.expectError { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + if appURL != tt.expectedURL { + t.Errorf("getAppURL() = %q, want %q", appURL, tt.expectedURL) + } + }) + } +} + +func TestValidateDomain(t *testing.T) { + tests := []struct { + name string + domain string + createDomain bool + createClusterDomain bool + expectError bool + }{ + { + name: "domain found in namespace Domains", + domain: "auth.service.local", + createDomain: true, + }, + { + name: "domain found in ClusterDomains", + domain: "external.service.sap", + createClusterDomain: true, + }, + { + name: "domain not found anywhere", + domain: "nonexistent.domain.com", + expectError: true, + }, + { + name: "domain not matching but resources exist", + domain: "other.domain.com", + createDomain: true, + createClusterDomain: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtimeObjs := []runtime.Object{} + if tt.createDomain { + runtimeObjs = append(runtimeObjs, createDomain()) + } + if tt.createClusterDomain { + runtimeObjs = append(runtimeObjs, createClusterDomain()) + } + + subHandler := setup(nil, createSecrets(), runtimeObjs...) + err := subHandler.validateDomain(tt.domain, v1.NamespaceDefault) + + if tt.expectError && err == nil { + t.Error("expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + }) + } +} + func execTestsWithBLI(t *testing.T, name string, backlogItems []string, test func(t *testing.T)) { t.Run(name+", BLIs: "+strings.Join(backlogItems, ", "), test) }