Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Subscription server getDependencies #116

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
148 changes: 146 additions & 2 deletions cmd/server/internal/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -70,6 +71,7 @@ const (
Step = "step"
TenantProvisioning = "Tenant Provisioning"
TenantDeprovisioning = "Tenant Deprovisioning"
GetDependencies = "Get Dependencies"
)

type Result struct {
Expand All @@ -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
Expand Down Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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{}}
}
Expand Down
145 changes: 145 additions & 0 deletions cmd/server/internal/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}`),
},
Expand Down Expand Up @@ -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)
}
}
})
}
}
1 change: 1 addition & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func main() {
klog.SetLogger(util.GetLogger())
subHandler := getSubscriptionHandler()
http.HandleFunc("/provision/", subHandler.HandleRequest)
http.HandleFunc("/dependencies/{globalAccountId}/{appName}", subHandler.HandleGetDependenciesRequest)

// Default port
port := "4000"
Expand Down
1 change: 1 addition & 0 deletions crds/sme.sap.com_capapplications.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ spec:
- services
type: object
btpAppName:
pattern: ^[a-z0-9_-]+$
type: string
domains:
properties:
Expand Down
1 change: 1 addition & 0 deletions pkg/apis/sme.sap.com/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading