diff --git a/cmd/server/internal/handler.go b/cmd/server/internal/handler.go index 92c5284..d61ecb8 100644 --- a/cmd/server/internal/handler.go +++ b/cmd/server/internal/handler.go @@ -56,6 +56,7 @@ const InvalidRequestMethod = "invalid request method" const AuthorizationCheckFailed = "authorization check failed" const BearerPrefix = "Bearer " const BasicPrefix = "Basic " +const ContentType = "Content-Type" const ( CallbackSucceeded = "SUCCEEDED" @@ -70,6 +71,7 @@ const ( Step = "step" TenantProvisioning = "Tenant Provisioning" TenantDeprovisioning = "Tenant Deprovisioning" + GetDependencies = "Get Dependencies" ) type Result struct { @@ -89,10 +91,17 @@ type CallbackResponse struct { SubscriptionUrl string `json:"subscriptionUrl"` AdditionalOutput *map[string]any `json:"additionalOutput,omitempty"` } + type OAuthResponse struct { AccessToken string `json:"access_token"` } +type GetDependenciesAuthError struct{} + +func (err *GetDependenciesAuthError) Error() string { + return "Not authorized" +} + type tenantInfo struct { tenantId string tenantSubDomain string @@ -550,7 +559,7 @@ func prepareTokenRequest(ctx context.Context, saasData *util.SaasRegistryCredent if err != nil { return nil, err } - tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenReq.Header.Set(ContentType, "application/x-www-form-urlencoded") if saasData.CredentialType != "x509" { tokenReq.Header.Set("Authorization", BasicPrefix+base64.StdEncoding.EncodeToString([]byte(saasData.ClientId+":"+saasData.ClientSecret))) } @@ -598,7 +607,7 @@ func (s *SubscriptionHandler) handleAsyncCallback(ctx context.Context, saasData AdditionalOutput: additionalOutput, }) callbackReq, _ := http.NewRequestWithContext(ctx, http.MethodPut, saasData.SaasManagerUrl+asyncCallbackPath, bytes.NewBuffer(payload)) - callbackReq.Header.Set("Content-Type", "application/json") + callbackReq.Header.Set(ContentType, "application/json") callbackReq.Header.Set("Authorization", BearerPrefix+oAuthType.AccessToken) client := s.httpClientGenerator.NewHTTPClient() @@ -643,6 +652,141 @@ func (s *SubscriptionHandler) HandleRequest(w http.ResponseWriter, req *http.Req w.Write(res) } +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]interface{} + + 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 + } + + if service.Class == "destination" || service.Class == "connectivity" || (service.Class == "auditlog" && credentials["plan"] == "oauth2") { + if checkXsAppNameInCredentials(credentials) { + return map[string]interface{}{ + "appId": credentials["xsappname"].(string), + "appName": service.Class, + } + } else if checkXsAppNameInUaaCredentials(credentials) { + return map[string]interface{}{ + "appId": credentials["uaa"].(map[string]interface{})["xsappname"].(string), + "appName": service.Class, + } + } + } else if checkSaasRegistryAppNameInCredentials(credentials) && checkXsAppNameInUaaCredentials(credentials) { + return map[string]interface{}{ + "appId": credentials["uaa"].(map[string]interface{})["xsappname"].(string), + "appName": credentials["saasregistryappname"].(string), + } + } else if checkSaasRegistryEnabled(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 (s *SubscriptionHandler) getDependencies(req *http.Request) ([]byte, error) { + var dependenciesArray []map[string]interface{} + + // Read the cap application by using the global-account-id & app-name passed in the URI + // URI format - /dependencies/global-account-id/app-name?tenantId= + globalAccountId := req.PathValue("globalAccountId") + appName := req.PathValue("appName") + if globalAccountId == "" || appName == "" { + err := errors.New("wrong get dependencies request uri - globalAccountId or appName not found") + util.LogError(err, "Wrong get dependencies request URI - globalAccountId or appName not found", GetDependencies, "InvalidURI", nil, "uri", req.RequestURI) + return nil, err + } + + util.LogInfo("Get dependencies endpoint called", GetDependencies, "GetDependencies", nil, "globalAccountId", globalAccountId, "btpAppName", appName) + + ca, err := s.checkCAPApp(globalAccountId, appName) + if err != nil { + util.LogError(err, "CAP Application resource not found", GetDependencies, nil, nil, "globalAccountId", globalAccountId, "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, "globalAccountId", globalAccountId, "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, "globalAccountId", globalAccountId, "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, "globalAccountId", globalAccountId, "btpAppName", appName) + return nil, nil + } + + dependencies, err := json.Marshal(dependenciesArray) + if err != nil { + util.LogError(err, "Json marshal of dependencies failed", GetDependencies, ca, nil, "globalAccountId", globalAccountId, "btpAppName", appName) + return nil, err + } + + util.LogInfo("Dependencies returned", GetDependencies, ca, nil, "globalAccountId", globalAccountId, "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 c39421d..5a66ebc 100644 --- a/cmd/server/internal/handler_test.go +++ b/cmd/server/internal/handler_test.go @@ -115,6 +115,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" }`), }, @@ -725,3 +784,89 @@ func TestMultiXSUAA(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 c489098..49eabb1 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -23,6 +23,7 @@ func main() { subHandler := getSubscriptionHandler() http.HandleFunc("/provision/", util.InstrumentHttpHandler(subHandler.HandleRequest, "cap_op_subscription_requests", "subscription-server requests.")) + http.HandleFunc("/dependencies/{globalAccountId}/{appName}", util.InstrumentHttpHandler(subHandler.HandleGetDependenciesRequest, "cap_op_dependencies_requests", "subscription-server dependencies requests.")) // Initialize/start metrics server util.InitMetricsServer() diff --git a/crds/sme.sap.com_capapplications.yaml b/crds/sme.sap.com_capapplications.yaml index 1251078..0aa96af 100644 --- a/crds/sme.sap.com_capapplications.yaml +++ b/crds/sme.sap.com_capapplications.yaml @@ -56,6 +56,7 @@ spec: - services type: object btpAppName: + pattern: ^[a-z0-9_-]+$ type: string domains: properties: diff --git a/pkg/apis/sme.sap.com/v1alpha1/types.go b/pkg/apis/sme.sap.com/v1alpha1/types.go index b5c8cf1..a012ee6 100644 --- a/pkg/apis/sme.sap.com/v1alpha1/types.go +++ b/pkg/apis/sme.sap.com/v1alpha1/types.go @@ -83,6 +83,7 @@ type CAPApplicationSpec struct { // SAP BTP Global Account Identifier where services are entitles for the current application GlobalAccountId string `json:"globalAccountId"` // Short name for the application (similar to BTP XSAPPNAME) + // +kubebuilder:validation:Pattern=^[a-z0-9_-]+$ BTPAppName string `json:"btpAppName"` // Provider subaccount where application services are created Provider BTPTenantIdentification `json:"provider"` 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 91ab402..1ddd7b3 100644 --- a/website/content/en/docs/concepts/operator-components/subscription-server.md +++ b/website/content/en/docs/concepts/operator-components/subscription-server.md @@ -7,13 +7,13 @@ description: > Integration with SAP Software-as-a-Service Provisioning service (SaaS) --- -The Subscription Server handles HTTP requests from the [SAP Software-as-a-Service Provisioning service](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/2cd8913a50bc4d3e8172f84bb4bfba20.html) for tenant subscription operations on SAP Cloud Application Programming Model applications that have been installed in the cluster. +The Subscription Server handles HTTP requests from the [SAP Software-as-a-Service Provisioning service](https://help.sap.com/viewer/65de2977205c403bbc107264b8eccf4b/Cloud/en-US/2cd8913a50bc4d3e8172f84bb4bfba20.html) for tenant subscription operations on SAP Cloud Application Programming Model applications that have been installed in the cluster. -During the creation of a `saas-registry` service instance (in the provider subaccount), [callback URLs are configured](../../../usage/prerequisites/#saas-provisioning-service), which 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/#saas-provisioning-service), which 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, a subscription callback is received by the subscription server, which then generates the `CAPTenant` custom resource object. +When a consumer tenant subscribes to an application managed by the operator, a subscription callback is received by the subscription server, which then generates the `CAPTenant` custom resource object. -The subscription server returns an `Accepted` (202) response code and starts a routine/thread, which keeps polling for the tenant status until the changes to the `CAPTenant` are then independently reconciled by the controller. +The subscription server returns an `Accepted` (202) response code and starts a routine/thread, which keeps polling for the tenant status until the changes to the `CAPTenant` are then independently reconciled by the controller. Once the tenant provisioning process has completed (or has failed), the tracking routine will return the appropriate status to the SaaS Registry via an asynchronous callback (by obtaining the necessary authorization token). diff --git a/website/content/en/docs/usage/prerequisites.md b/website/content/en/docs/usage/prerequisites.md index 8ee2ee8..8deafaf 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 # <-- used to fail subscription process when no response is received - 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