From 1896d431c44aebb7c1c099ec15d64b0feb934c9c Mon Sep 17 00:00:00 2001 From: anirudhprasad-sap <126493692+anirudhprasad-sap@users.noreply.github.com> Date: Fri, 23 Aug 2024 13:59:11 +0000 Subject: [PATCH] [Feat] Subscription: getDependencies implemented Add support for getDependencies within CAP Operator. The subscription server now also supports getDependencies callback. So, consuming apps can foward both calls to CAP Operator itself! The implementation is similar to what approuter supports internally. Co-authored-by: Pavan <25031267+Pavan-SAP@users.noreply.github.com> --- cmd/server/internal/handler.go | 139 +++++++++++++++++ cmd/server/internal/handler_test.go | 145 ++++++++++++++++++ cmd/server/server.go | 2 + crds/sme.sap.com_capapplications.yaml | 6 + pkg/apis/sme.sap.com/v1alpha1/types.go | 14 ++ pkg/apis/sme.sap.com/v1alpha1/utils.go | 7 + .../v1alpha1/zz_generated.deepcopy.go | 9 +- .../sme.sap.com/v1alpha1/serviceinfo.go | 14 ++ .../subscription-server.md | 2 +- .../content/en/docs/usage/prerequisites.md | 2 +- 10 files changed, 337 insertions(+), 3 deletions(-) diff --git a/cmd/server/internal/handler.go b/cmd/server/internal/handler.go index 87c64ce8..c884ba95 100644 --- a/cmd/server/internal/handler.go +++ b/cmd/server/internal/handler.go @@ -63,6 +63,7 @@ const InvalidRequestMethod = "invalid request method" const AuthorizationCheckFailed = "authorization check failed" const BearerPrefix = "Bearer " const BasicPrefix = "Basic " +const ContentType = "Content-Type" const ( CallbackSucceeded = "SUCCEEDED" @@ -77,6 +78,7 @@ const ( Step = "step" TenantProvisioning = "Tenant Provisioning" TenantDeprovisioning = "Tenant Deprovisioning" + GetDependencies = "Get Dependencies" ) type RequestInfo struct { @@ -157,6 +159,12 @@ type tenantInfo struct { tenantSubDomain string } +type GetDependenciesAuthError struct{} + +func (err *GetDependenciesAuthError) Error() string { + return "Not authorized" +} + func (s *SubscriptionHandler) CreateTenant(reqInfo *RequestInfo) *Result { util.LogInfo("Create Tenant triggered", TenantProvisioning, "CreateTenant", nil) var created, updated = false, false @@ -1125,6 +1133,137 @@ func getSubscriptionDomain(payload map[string]any) string { return "" } +func checkXsAppNameInUaaCredentials(credential map[string]interface{}) bool { + return credential["uaa"] != nil && credential["uaa"].(map[string]interface{})["xsappname"] != nil && credential["uaa"].(map[string]interface{})["xsappname"].(string) != "" +} + +func checkXsAppNameInCredentials(credential map[string]interface{}) bool { + return credential["xsappname"] != nil && credential["xsappname"].(string) != "" +} + +func checkSaasRegistryAppNameInCredentials(credential map[string]interface{}) bool { + return credential["saasregistryappname"] != nil && credential["saasregistryappname"].(string) != "" +} + +func checkSaasRegistryEnabled(credential map[string]interface{}) bool { + return credential["saasregistryenabled"] != nil && credential["saasregistryenabled"].(bool) +} + +func (s *SubscriptionHandler) getServiceDependencies(capApp *v1alpha1.CAPApplication, service v1alpha1.ServiceInfo) map[string]interface{} { + var credentials map[string]any + + serviceSecretCred, err := s.KubeClienset.CoreV1().Secrets(capApp.Namespace).Get(context.TODO(), service.Secret, metav1.GetOptions{}) + if err != nil { + util.LogError(err, "Service secret read failed", GetDependencies, capApp, nil, "secretName", service.Secret) + return nil + } + + if err = json.Unmarshal(serviceSecretCred.Data["credentials"], &credentials); err != nil { + util.LogError(err, "Could not read xsuaa secret with key credentials", GetDependencies, capApp, nil, "secretName", service.Secret) + return nil + } + + // Subscription Dependency check + if isServiceRelevantForDependencies(service, credentials) { + if checkXsAppNameInCredentials(credentials) { + return map[string]interface{}{ + "xsappname": credentials["xsappname"].(string), + } + } else if checkXsAppNameInUaaCredentials(credentials) { + return map[string]interface{}{ + "xsappname": credentials["uaa"].(map[string]interface{})["xsappname"].(string), + } + } + } + return nil +} + +func isServiceRelevantForDependencies(serviceInfo v1alpha1.ServiceInfo, credentials map[string]any) bool { + if serviceInfo.GetSubscriptionDependency() == v1alpha1.SubscriptionDependencyAlways { + return true + } + + if serviceInfo.GetSubscriptionDependency() == v1alpha1.SubscriptionDependencyAuto { + return serviceInfo.Class == "destination" || serviceInfo.Class == "connectivity" || (serviceInfo.Class == "auditlog" && credentials["plan"] == "oauth2") || (checkSaasRegistryAppNameInCredentials(credentials) && checkXsAppNameInUaaCredentials(credentials)) || checkSaasRegistryEnabled(credentials) + } + + return false +} + +func (s *SubscriptionHandler) getDependencies(req *http.Request) ([]byte, error) { + var dependenciesArray []map[string]interface{} + + // Read the cap application by using the provider subaccount id & app-name passed in the URI + // URI format - /dependencies/providersubaccountId/app-name?tenantId= + providersubaccountId := req.PathValue("providersubaccountId") + appName := req.PathValue("appName") + if providersubaccountId == "" || appName == "" { + err := errors.New("wrong get dependencies request uri - providersubaccountId or appName not found") + util.LogError(err, "Wrong get dependencies request URI - providersubaccountId or appName not found", GetDependencies, "InvalidURI", nil, "uri", req.RequestURI) + return nil, err + } + + util.LogInfo("Get dependencies endpoint called", GetDependencies, "GetDependencies", nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + + ca, err := s.checkCAPApp("", providersubaccountId, appName) + if err != nil { + util.LogError(err, "CAP Application resource not found", GetDependencies, nil, nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + return nil, err + } + + // fetch SaaS Registry and XSUAA information + saasData, uaaData := s.getServiceDetails(ca, GetDependencies) + if saasData == nil || uaaData == nil { + util.LogError(err, "Cannot read saas registry and xsuaa information", GetDependencies, nil, nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + return nil, err + } + + // validate token + if err = s.checkAuthorization(req.Header.Get("Authorization"), saasData, uaaData, GetDependencies); err != nil { + util.LogError(err, "Authorization check failed", GetDependencies, "checkAuthorization", nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + return nil, &GetDependenciesAuthError{} + } + + for _, service := range ca.Spec.BTP.Services { + if serviceDependency := s.getServiceDependencies(ca, service); serviceDependency != nil { + dependenciesArray = append(dependenciesArray, serviceDependency) + } + } + + if len(dependenciesArray) == 0 { + util.LogInfo("No dependencies found", GetDependencies, ca, nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + return nil, nil + } + + dependencies, err := json.Marshal(dependenciesArray) + if err != nil { + util.LogError(err, "Json marshal of dependencies failed", GetDependencies, ca, nil, "providersubaccountId", providersubaccountId, "btpAppName", appName) + return nil, err + } + + util.LogInfo("Dependencies returned", GetDependencies, ca, nil, "providersubaccountId", providersubaccountId, "btpAppName", appName, "dependencies", string(dependencies)) + + return dependencies, nil +} + +func (s *SubscriptionHandler) HandleGetDependenciesRequest(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + dependencies, err := s.getDependencies(req) + if err != nil { + if _, ok := err.(*GetDependenciesAuthError); ok { + w.WriteHeader(http.StatusUnauthorized) + } else { + w.WriteHeader(http.StatusBadRequest) + } + } else { + w.Header().Set(ContentType, "application/json") + w.Write(dependencies) + } + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} 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 dc489a1a..b4767d69 100644 --- a/cmd/server/internal/handler_test.go +++ b/cmd/server/internal/handler_test.go @@ -110,6 +110,65 @@ func createSecrets() []runtime.Object { "uaadomain": "auth.service.local", "sburl": "internal.auth.service.local", "url": "https://app-domain.auth.service.local", + "saasregistryenabled": true, + "uaa": {"xsappname": "appname!b15" }, + "credential-type": "instance-secret" + }`), + }, + }, &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-dest-sec", + Namespace: v1.NamespaceDefault, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "credentials": []byte(`{ + "saas_registry_url": "https://sm.service.local", + "clientid": "clientid", + "clientsecret": "clientsecret", + "uaadomain": "auth.service.local", + "sburl": "internal.auth.service.local", + "url": "https://app-domain.auth.service.local", + "saasregistryenabled": true, + "uaa": {"xsappname": "appname!b15" }, + "credential-type": "instance-secret" + }`), + }, + }, &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-html-rt-sec", + Namespace: v1.NamespaceDefault, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "credentials": []byte(`{ + "saas_registry_url": "https://sm.service.local", + "clientid": "clientid", + "clientsecret": "clientsecret", + "uaadomain": "auth.service.local", + "sburl": "internal.auth.service.local", + "url": "https://app-domain.auth.service.local", + "saasregistryappname": "saasregistryappname", + "uaa": {"xsappname": "appname!b15" }, + "credential-type": "instance-secret" + }`), + }, + }, &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-sm-sec", + Namespace: v1.NamespaceDefault, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "credentials": []byte(`{ + "saas_registry_url": "https://sm.service.local", + "clientid": "clientid", + "clientsecret": "clientsecret", + "uaadomain": "auth.service.local", + "sburl": "internal.auth.service.local", + "url": "https://app-domain.auth.service.local", + "saasregistryenabled": true, + "xsappname": "appname!b15", "credential-type": "instance-secret" }`), }, @@ -1545,3 +1604,89 @@ func TestValidateDomain(t *testing.T) { func execTestsWithBLI(t *testing.T, name string, backlogItems []string, test func(t *testing.T)) { t.Run(name+", BLIs: "+strings.Join(backlogItems, ", "), test) } + +func TestGetDependencies(t *testing.T) { + tests := []struct { + name string + method string + invalidToken bool + invalidURI bool + expectedStatusCode int + expectedResponse []map[string]string + }{ + { + name: "Invalid get dependency request - wrong method", + method: http.MethodPut, + expectedStatusCode: http.StatusMethodNotAllowed, + expectedResponse: nil, + }, + { + name: "Not authorized request", + method: http.MethodGet, + invalidToken: true, + expectedStatusCode: http.StatusUnauthorized, + expectedResponse: nil, + }, + { + name: "Invalid URI", + method: http.MethodGet, + invalidURI: true, + expectedStatusCode: http.StatusBadRequest, + expectedResponse: nil, + }, + { + name: "Valid get dependency request", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + expectedResponse: []map[string]string{ + {"xsappname": "appname!b15"}, + {"xsappname": "appname!b15"}, + {"appId": "appname!b15", "appName": "destination"}, + {"appId": "appname!b15", "appName": "saasregistryappname"}, + }, + }, + } + + for _, testData := range tests { + t.Run(testData.name, func(t *testing.T) { + ca := createCA() + + client, tokenString, err := SetupValidTokenAndIssuerForSubscriptionTests("appname!b14") + if err != nil { + t.Fatal(err.Error()) + } + subHandler := setup(ca, nil, nil, client) + + res := httptest.NewRecorder() + var req *http.Request + if testData.invalidURI == true { + req = httptest.NewRequest(testData.method, "/callback/dependencies/globalAccountId/{appName}", nil) + req.SetPathValue("appName", appName) + } else { + req = httptest.NewRequest(testData.method, "/dependencies/{globalAccountId}/{appName}", nil) + req.SetPathValue("globalAccountId", globalAccountId) + req.SetPathValue("appName", appName) + } + + if testData.invalidToken == true { + tokenString = "abc" //invalid token + } + + req.Header.Set("Authorization", "Bearer "+tokenString) + subHandler.HandleGetDependenciesRequest(res, req) + + if res.Code != testData.expectedStatusCode { + t.Errorf("Expected status '%d', received '%d'", testData.expectedStatusCode, res.Code) + } + + // Get the relevant response + if res.Code == http.StatusOK { + resBodyStr := res.Body.String() + expectedResponseByte, _ := json.Marshal(testData.expectedResponse) + if resBodyStr != string(expectedResponseByte) { + t.Error("Unexpected error in expected response: ", res.Body) + } + } + }) + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go index f40c0bc2..b32f52f8 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -29,6 +29,8 @@ func main() { http.HandleFunc("/provision/", util.InstrumentHttpHandler(subHandler.HandleSaaSRequest, subsctiptionHandlerMetricPrefix, subscriptionHandlerDesc)) http.HandleFunc("/sms/provision/", util.InstrumentHttpHandler(subHandler.HandleSMSRequest, subsctiptionHandlerMetricPrefix+"_sms", subscriptionHandlerDesc)) + // TODO: instrument this route too + http.HandleFunc("/dependencies/{providerSubaccountId}/{appName}/", subHandler.HandleGetDependenciesRequest) // Initialize/start metrics server util.InitMetricsServer() diff --git a/crds/sme.sap.com_capapplications.yaml b/crds/sme.sap.com_capapplications.yaml index 17bdf1f4..fa5c4934 100644 --- a/crds/sme.sap.com_capapplications.yaml +++ b/crds/sme.sap.com_capapplications.yaml @@ -46,6 +46,12 @@ spec: type: string secret: type: string + subscriptionDependency: + enum: + - Auto + - Always + - Never + type: string required: - class - name diff --git a/pkg/apis/sme.sap.com/v1alpha1/types.go b/pkg/apis/sme.sap.com/v1alpha1/types.go index 801e26fb..f0f920f7 100644 --- a/pkg/apis/sme.sap.com/v1alpha1/types.go +++ b/pkg/apis/sme.sap.com/v1alpha1/types.go @@ -163,8 +163,22 @@ type ServiceInfo struct { Secret string `json:"secret"` // Type of service Class string `json:"class"` + // SubscriptionDependency may be used to specify whether this service should be part of getDependencies call from subscripton service (e.g. saas-registry) + SubscriptionDependency *SubscriptionDependency `json:"subscriptionDependency,omitempty"` } +// +kubebuilder:validation:Enum=Auto;Always;Never +type SubscriptionDependency string + +const ( + // Automatically determine if the service needs to be returned in getDependencies call + SubscriptionDependencyAuto SubscriptionDependency = "Auto" + // Always return the service in getDependencies call + SubscriptionDependencyAlways SubscriptionDependency = "Always" + // Never return the service in getDependencies call + SubscriptionDependencyNever SubscriptionDependency = "Never" +) + // Custom resource status type GenericStatus struct { // Observed generation of the resource where this status was identified diff --git a/pkg/apis/sme.sap.com/v1alpha1/utils.go b/pkg/apis/sme.sap.com/v1alpha1/utils.go index 4c0f89ed..eaa119a8 100644 --- a/pkg/apis/sme.sap.com/v1alpha1/utils.go +++ b/pkg/apis/sme.sap.com/v1alpha1/utils.go @@ -211,3 +211,10 @@ func (cdom *ClusterDomain) GetStatusReadyConditionMessage() string { } return "" } + +func (serviceInfo ServiceInfo) GetSubscriptionDependency() SubscriptionDependency { + if serviceInfo.SubscriptionDependency == nil { + return SubscriptionDependencyAuto + } + return *serviceInfo.SubscriptionDependency +} diff --git a/pkg/apis/sme.sap.com/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/sme.sap.com/v1alpha1/zz_generated.deepcopy.go index f9154644..0cfcd5df 100644 --- a/pkg/apis/sme.sap.com/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/sme.sap.com/v1alpha1/zz_generated.deepcopy.go @@ -50,7 +50,9 @@ func (in *BTP) DeepCopyInto(out *BTP) { if in.Services != nil { in, out := &in.Services, &out.Services *out = make([]ServiceInfo, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } return } @@ -1248,6 +1250,11 @@ func (in *ServiceExposure) DeepCopy() *ServiceExposure { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceInfo) DeepCopyInto(out *ServiceInfo) { *out = *in + if in.SubscriptionDependency != nil { + in, out := &in.SubscriptionDependency, &out.SubscriptionDependency + *out = new(SubscriptionDependency) + **out = **in + } return } diff --git a/pkg/client/applyconfiguration/sme.sap.com/v1alpha1/serviceinfo.go b/pkg/client/applyconfiguration/sme.sap.com/v1alpha1/serviceinfo.go index eb6d6d72..b14f97c9 100644 --- a/pkg/client/applyconfiguration/sme.sap.com/v1alpha1/serviceinfo.go +++ b/pkg/client/applyconfiguration/sme.sap.com/v1alpha1/serviceinfo.go @@ -7,6 +7,10 @@ SPDX-License-Identifier: Apache-2.0 package v1alpha1 +import ( + smesapcomv1alpha1 "github.com/sap/cap-operator/pkg/apis/sme.sap.com/v1alpha1" +) + // ServiceInfoApplyConfiguration represents a declarative configuration of the ServiceInfo type for use // with apply. // @@ -18,6 +22,8 @@ type ServiceInfoApplyConfiguration struct { Secret *string `json:"secret,omitempty"` // Type of service Class *string `json:"class,omitempty"` + // SubscriptionDependency may be used to specify whether this service should be part of getDependencies call from subscripton service (e.g. saas-registry) + SubscriptionDependency *smesapcomv1alpha1.SubscriptionDependency `json:"subscriptionDependency,omitempty"` } // ServiceInfoApplyConfiguration constructs a declarative configuration of the ServiceInfo type for use with @@ -49,3 +55,11 @@ func (b *ServiceInfoApplyConfiguration) WithClass(value string) *ServiceInfoAppl b.Class = &value return b } + +// WithSubscriptionDependency sets the SubscriptionDependency field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the SubscriptionDependency field is set to the value of the last call. +func (b *ServiceInfoApplyConfiguration) WithSubscriptionDependency(value smesapcomv1alpha1.SubscriptionDependency) *ServiceInfoApplyConfiguration { + b.SubscriptionDependency = &value + return b +} diff --git a/website/content/en/docs/concepts/operator-components/subscription-server.md b/website/content/en/docs/concepts/operator-components/subscription-server.md index e9b47e90..bb169fb2 100644 --- a/website/content/en/docs/concepts/operator-components/subscription-server.md +++ b/website/content/en/docs/concepts/operator-components/subscription-server.md @@ -9,7 +9,7 @@ description: > The Subscription Server handles HTTP requests from the [SAP Software-as-a-Service Provisioning service](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/5e8a2b74e4f2442b8257c850ed912f48.html) for tenant subscription operations on SAP Cloud Application Programming Model applications installed in the cluster. -During the creation of a `saas-registry` service instance (in the provider subaccount), [callback URLs are configured](../../../usage/prerequisites/#sap-software-as-a-service-provisioning-service) to point to the subscription server routes. +During the creation of a `saas-registry` service instance (in the provider subaccount), [callback URLs are configured](../../../usage/prerequisites/#sap-software-as-a-service-provisioning-service) to point to the subscription server routes. Additionally, the `getDependecies` URLs can also be configured to point to the subscription server routes. When a consumer tenant subscribes to an application managed by the operator, the subscription server receives the callback, validates the request, and creates a `CAPTenant` custom resource object for the identified `CAPApplication`. diff --git a/website/content/en/docs/usage/prerequisites.md b/website/content/en/docs/usage/prerequisites.md index 0323a2f1..dcf885f0 100644 --- a/website/content/en/docs/usage/prerequisites.md +++ b/website/content/en/docs/usage/prerequisites.md @@ -90,7 +90,7 @@ parameters: appName: appUrls: callbackTimeoutMillis: 300000 # <-- fails the subscription process when no response is received within this timeout - getDependencies: https://..cluster-x.my-project.shoot.url.k8s.example.com/callback/v1.0/dependencies # <-- handled by the application + getDependencies: https:///dependencies/// # the /getDependencies route is forwarded directly to CAP Operator (Subscription Server) and must be specified as such onSubscription: https:///provision/tenants/{tenantId} # <-- the /provision route is forwarded directly to CAP Operator (Subscription Server) and must be specified as such onSubscriptionAsync: true onUnSubscriptionAsync: true