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