diff --git a/.github/workflows/sqaaas.yaml b/.github/workflows/sqaaas.yaml new file mode 100644 index 00000000..9813070f --- /dev/null +++ b/.github/workflows/sqaaas.yaml @@ -0,0 +1,25 @@ +name: SQAaaS OSCAR + +on: + push: + branches: ["sqa"] + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 +jobs: + + sqaaas_job: + runs-on: ubuntu-latest + steps: + - name: Add tox unit test step definition for a SQAaaS assessment + uses: eosc-synergy/sqaaas-step-action@v1 + id: go_unit_test + with: + name: go_unit_test + container: "golang:1.21.4-alpine3.18" + tool: commands + commands: "go test ./... -v" + + - name: SQAaaS assessment step + uses: eosc-synergy/sqaaas-assessment-action@v2 + with: + qc_uni_steps: go_unit_test diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3017a1ad..9d0cb12f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: go-version: '1.21' diff --git a/docs/integration-egi.md b/docs/integration-egi.md index 4356f57f..f7a348c2 100644 --- a/docs/integration-egi.md +++ b/docs/integration-egi.md @@ -98,6 +98,7 @@ Once logged in via EGI Check-In you can obtain an Access Token with one of this oidc-token ``` where `account-short-name` is the name of your account configuration. + * From the EGI Check-In Token Portal: [https://aai.egi.eu/token](https://aai.egi.eu/token) ![egi-checkin-token-portal.png](images/oidc/egi-checkin-token-portal.png) diff --git a/examples/stable-diffusion/Dockerfile b/examples/stable-diffusion/Dockerfile new file mode 100644 index 00000000..65bdbd95 --- /dev/null +++ b/examples/stable-diffusion/Dockerfile @@ -0,0 +1,21 @@ +FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 + +RUN apt update && \ + apt install -y --no-install-recommends git wget python3-pip && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/srisco/stable-diffusion-tensorflow.git + +WORKDIR stable-diffusion-tensorflow + +RUN pip install -r requirements.txt && \ + rm -rf /root/.cache/pip/* && \ + rm -rf /tmp/* + +# DOWNLOAD WEIGHTS +RUN mkdir -p /root/.keras/datasets && \ + wget https://huggingface.co/fchollet/stable-diffusion/resolve/main/text_encoder.h5 -O /root/.keras/datasets/text_encoder.h5 && \ + wget https://huggingface.co/fchollet/stable-diffusion/resolve/main/diffusion_model.h5 -O /root/.keras/datasets/diffusion_model.h5 && \ + wget https://huggingface.co/fchollet/stable-diffusion/resolve/main/decoder.h5 -O /root/.keras/datasets/decoder.h5 && \ + wget https://huggingface.co/divamgupta/stable-diffusion-tensorflow/resolve/main/encoder_newW.h5 -O /root/.keras/datasets/encoder_newW.h5 diff --git a/examples/stable-diffusion/README.md b/examples/stable-diffusion/README.md new file mode 100644 index 00000000..3f8b7512 --- /dev/null +++ b/examples/stable-diffusion/README.md @@ -0,0 +1,31 @@ +# Stable Diffusion + +This example is based on a Keras / Tensorflow implementation of Stable Diffusion. The following repositories were used for the creation of the image: + +* [srisco/stable-diffusion-tensorflow](https://github.com/srisco/stable-diffusion-tensorflow) +* [huggingface.co/fchollet/stable-diffusion](https://huggingface.co/fchollet/stable-diffusion) +* [huggingface.co/divamgupta/stable-diffusion-tensorflow](https://huggingface.co/divamgupta/stable-diffusion-tensorflow/) + +The image if pushed to a public github registry [here](ghcr.io/grycap/stable-diffusion-tf:latest) but you can see the Dockerfile that generates it [here](Dockerfile). + +## Deploy an OSCAR cluster +Follow the instructions in the documentation for your desired IaaS cloud provider. +[See Deployment](https://docs.oscar.grycap.net/) + +## Create the OSCAR Service + +The Service can be created using the OSCAR GUI by providing the [FDL](stable-diff.yaml) and the [script.sh](script.sh) file. + +![OSCAR GUI Creation of a service](https://oscar.grycap.net/images/blog/post-20210803-1/create_service_gui.png) + +## Upload the input file to the MinIO bucket + +Once the service is created, you can upload the input file to the bucket. The input file should be a file containing the prompt that you want to process. + +For example, using the following prompt: + +`a chicken making a 360 with a skateboard with background flames` + +The following image is generated: + +![Cool chicken](prompt.txt.png) \ No newline at end of file diff --git a/examples/stable-diffusion/prompt.txt b/examples/stable-diffusion/prompt.txt new file mode 100644 index 00000000..b27d4aad --- /dev/null +++ b/examples/stable-diffusion/prompt.txt @@ -0,0 +1 @@ +a chicken making a 360 with a skateboard with background flames diff --git a/examples/stable-diffusion/prompt.txt.png b/examples/stable-diffusion/prompt.txt.png new file mode 100644 index 00000000..543cffb6 Binary files /dev/null and b/examples/stable-diffusion/prompt.txt.png differ diff --git a/examples/stable-diffusion/script.sh b/examples/stable-diffusion/script.sh new file mode 100644 index 00000000..8bb41054 --- /dev/null +++ b/examples/stable-diffusion/script.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo "SCRIPT: Invoked stable diffusion text to image." +FILE_NAME=`basename "$INPUT_FILE_PATH"` +OUTPUT_FILE="$TMP_OUTPUT_DIR/$FILE_NAME.png" + +prompt=`cat "$INPUT_FILE_PATH"` +echo "SCRIPT: Converting input prompt '$INPUT_FILE_PATH' to image :)" +python3 text2image.py --prompt="$prompt" --output=$OUTPUT_FILE \ No newline at end of file diff --git a/examples/stable-diffusion/stable-diff.yaml b/examples/stable-diffusion/stable-diff.yaml new file mode 100644 index 00000000..2ea74461 --- /dev/null +++ b/examples/stable-diffusion/stable-diff.yaml @@ -0,0 +1,17 @@ +functions: + oscar: + - oscar-intertwin: + name: stable-diffusion-tf + memory: 16Gi + cpu: '4' + image: ghcr.io/grycap/stable-diffusion-tf:latest + script: script.sh + log_level: DEBUG + vo: "vo.example.eu" + allowed_users: [] + input: + - storage_provider: minio.default + path: stablediff/input + output: + - storage_provider: minio.default + path: stablediff/output diff --git a/go.mod b/go.mod index 508637a0..c29f3ffe 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( require ( github.com/fatih/color v1.14.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/rs/xid v1.4.0 // indirect diff --git a/go.sum b/go.sum index 67ddba40..0fadf17e 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,8 @@ github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXK github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/pkg/handlers/config_test.go b/pkg/handlers/config_test.go index 388c3c1a..7c74fcb5 100644 --- a/pkg/handlers/config_test.go +++ b/pkg/handlers/config_test.go @@ -43,6 +43,7 @@ func createExpectedBody(access_key string, secret_key string, cfg *types.Config) "gpu_available": false, "interLink_available": false, "yunikorn_enable": false, + "oidc_groups": nil, }, "minio_provider": map[string]interface{}{ "endpoint": cfg.MinIOProvider.Endpoint, diff --git a/pkg/handlers/create_test.go b/pkg/handlers/create_test.go index 335ab2d3..912763dd 100644 --- a/pkg/handlers/create_test.go +++ b/pkg/handlers/create_test.go @@ -38,7 +38,7 @@ func TestMakeCreateHandler(t *testing.T) { // Create a fake MinIO server server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { - if hreq.URL.Path != "/test" && hreq.URL.Path != "/test/input/" && hreq.URL.Path != "/output" && !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + if hreq.URL.Path != "/test" && hreq.URL.Path != "/test/input/" && hreq.URL.Path != "/test/output/" && hreq.URL.Path != "/test/mount/" && !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { t.Errorf("Unexpected path in request, got: %s", hreq.URL.Path) } @@ -88,10 +88,14 @@ func TestMakeCreateHandler(t *testing.T) { ], "output": [ { - "storage_provider": "webdav.id", - "path": "/output" + "storage_provider": "minio", + "path": "/test/output" } ], + "mount": { + "storage_provider": "minio", + "path": "/test/mount" + }, "storage_providers": { "webdav": { "id": { diff --git a/pkg/handlers/update_test.go b/pkg/handlers/update_test.go index 636caa9a..034b3fed 100644 --- a/pkg/handlers/update_test.go +++ b/pkg/handlers/update_test.go @@ -96,7 +96,7 @@ func TestMakeUpdateHandler(t *testing.T) { } } }, - "allowed_users": ["user1", "user2"] + "allowed_users": ["somelonguid1@egi.eu", "somelonguid2@egi.eu"] } `) req, _ := http.NewRequest("PUT", "/system/services", body) diff --git a/pkg/types/config.go b/pkg/types/config.go index b871ba86..6ad0a3a9 100644 --- a/pkg/types/config.go +++ b/pkg/types/config.go @@ -183,7 +183,7 @@ type Config struct { // OIDCGroups OpenID comma-separated group list to grant access in the cluster. // Groups defined in the "eduperson_entitlement" OIDC scope, // as described here: https://docs.egi.eu/providers/check-in/sp/#10-groups - OIDCGroups []string `json:"-"` + OIDCGroups []string `json:"oidc_groups"` // IngressHost string `json:"-"` diff --git a/pkg/types/expose_test.go b/pkg/types/expose_test.go index e0185f09..69bab5b3 100644 --- a/pkg/types/expose_test.go +++ b/pkg/types/expose_test.go @@ -22,6 +22,7 @@ import ( appsv1 "k8s.io/api/apps/v1" autoscalingv1 "k8s.io/api/autoscaling/v1" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" testclient "k8s.io/client-go/kubernetes/fake" @@ -229,3 +230,142 @@ func TestHortizontalAutoScaleSpec(t *testing.T) { t.Errorf("Expected target cpu 40 but got %d", res.Spec.TargetCPUUtilizationPercentage) } } + +func TestListIngress(t *testing.T) { + + K8sObjects := []runtime.Object{ + &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-ing", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + _, err := listIngress(kubeClientset, cfg) + + if err != nil { + t.Errorf("Error listing ingresses: %v", err) + } +} + +func TestUpdateIngress(t *testing.T) { + + K8sObjects := []runtime.Object{ + &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-ing", + Namespace: "namespace", + }, + }, + } + + service := Service{ + Name: "service", + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + err := updateIngress(service, kubeClientset, cfg) + + if err != nil { + t.Errorf("Error updating ingress: %v", err) + } +} + +func TestDeleteIngress(t *testing.T) { + + K8sObjects := []runtime.Object{ + &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-ing", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + err := deleteIngress("service-ing", kubeClientset, cfg) + + if err != nil { + t.Errorf("Error deleting ingress: %v", err) + } +} + +func TestUpdateSecret(t *testing.T) { + + K8sObjects := []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-auth-expose", + Namespace: "namespace", + }, + }, + } + service := Service{ + Name: "service", + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + err := updateSecret(service, kubeClientset, cfg) + + if err != nil { + t.Errorf("Error updating secret: %v", err) + } +} + +func TestDeleteSecret(t *testing.T) { + + K8sObjects := []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-auth-expose", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + err := deleteSecret("service", kubeClientset, cfg) + + if err != nil { + t.Errorf("Error deleting secret: %v", err) + } +} + +func TestExistsSecret(t *testing.T) { + + K8sObjects := []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-auth-expose", + Namespace: "namespace", + }, + }, + } + + kubeClientset := testclient.NewSimpleClientset(K8sObjects...) + cfg := &Config{ServicesNamespace: "namespace"} + + exists := existsSecret("service", kubeClientset, cfg) + + if exists != true { + t.Errorf("Expected secret to exist but got %v", exists) + } + + notexists := existsSecret("service1", kubeClientset, cfg) + + if notexists != false { + t.Errorf("Expected secret not to exist but got %v", notexists) + } +} diff --git a/pkg/utils/auth/auth.go b/pkg/utils/auth/auth.go index 559c6aad..57795a48 100644 --- a/pkg/utils/auth/auth.go +++ b/pkg/utils/auth/auth.go @@ -29,7 +29,7 @@ import ( ) // GetAuthMiddleware returns the appropriate gin auth middleware -func GetAuthMiddleware(cfg *types.Config, kubeClientset *kubernetes.Clientset) gin.HandlerFunc { +func GetAuthMiddleware(cfg *types.Config, kubeClientset kubernetes.Interface) gin.HandlerFunc { if !cfg.OIDCEnable { return gin.BasicAuth(gin.Accounts{ // Use the config's username and password for basic auth @@ -40,7 +40,7 @@ func GetAuthMiddleware(cfg *types.Config, kubeClientset *kubernetes.Clientset) g } // CustomAuth returns a custom auth handler (gin middleware) -func CustomAuth(cfg *types.Config, kubeClientset *kubernetes.Clientset) gin.HandlerFunc { +func CustomAuth(cfg *types.Config, kubeClientset kubernetes.Interface) gin.HandlerFunc { basicAuthHandler := gin.BasicAuth(gin.Accounts{ // Use the config's username and password for basic auth cfg.Username: cfg.Password, @@ -53,7 +53,7 @@ func CustomAuth(cfg *types.Config, kubeClientset *kubernetes.Clientset) gin.Hand minIOAdminClient.CreateAllUsersGroup() minIOAdminClient.UpdateUsersInGroup(oscarUser, "all_users_group", false) - oidcHandler := getOIDCMiddleware(kubeClientset, minIOAdminClient, cfg.OIDCIssuer, cfg.OIDCSubject, cfg.OIDCGroups) + oidcHandler := getOIDCMiddleware(kubeClientset, minIOAdminClient, cfg.OIDCIssuer, cfg.OIDCSubject, cfg.OIDCGroups, nil) return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { diff --git a/pkg/utils/auth/auth_test.go b/pkg/utils/auth/auth_test.go new file mode 100644 index 00000000..1a9c50b5 --- /dev/null +++ b/pkg/utils/auth/auth_test.go @@ -0,0 +1,145 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/grycap/oscar/v3/pkg/types" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetAuthMiddleware(t *testing.T) { + cfg := &types.Config{ + OIDCEnable: false, + Username: "testuser", + Password: "testpass", + } + kubeClientset := fake.NewSimpleClientset() + + router := gin.New() + router.Use(GetAuthMiddleware(cfg, kubeClientset)) + router.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + req.SetBasicAuth("testuser", "testpass") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %v, got %v", http.StatusOK, w.Code) + } + + we := httptest.NewRecorder() + reqe, _ := http.NewRequest("GET", "/", nil) + reqe.SetBasicAuth("testuser", "otherpass") + router.ServeHTTP(we, reqe) + + if we.Code != http.StatusUnauthorized { + t.Errorf("expected status %v, got %v", http.StatusUnauthorized, we.Code) + } +} + +func TestGetLoggerMiddleware(t *testing.T) { + router := gin.New() + router.Use(GetLoggerMiddleware()) + router.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %v, got %v", http.StatusOK, w.Code) + } +} + +func TestGetUIDFromContext(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("uidOrigin", "testuid") + + uid, err := GetUIDFromContext(c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if uid != "testuid" { + t.Errorf("expected uid %v, got %v", "testuid", uid) + } +} + +func TestGetMultitenancyConfigFromContext(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + mc := &MultitenancyConfig{} + c.Set("multitenancyConfig", mc) + + mcFromContext, err := GetMultitenancyConfigFromContext(c) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if mcFromContext != mc { + t.Errorf("expected multitenancyConfig %v, got %v", mc, mcFromContext) + } +} + +func TestCustomAuth(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if !strings.HasPrefix(hreq.URL.Path, "/minio/admin/v3/") { + t.Errorf("Unexpected path in request, got: %s", hreq.URL.Path) + } + if hreq.URL.Path == "/minio/admin/v3/info" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"Mode": "local", "Region": "us-east-1"}`)) + } else { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"status": "success"}`)) + } + })) + + cfg := &types.Config{ + OIDCEnable: false, + Username: "testuser", + Password: "testpass", + MinIOProvider: &types.MinIOProvider{ + Endpoint: server.URL, + AccessKey: "minio", + SecretKey: "minio123", + }, + } + kubeClientset := fake.NewSimpleClientset() + + router := gin.New() + router.Use(CustomAuth(cfg, kubeClientset)) + router.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + req.SetBasicAuth("testuser", "testpass") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %v, got %v", http.StatusOK, w.Code) + } +} diff --git a/pkg/utils/auth/multitenancy_test.go b/pkg/utils/auth/multitenancy_test.go new file mode 100644 index 00000000..2f93a055 --- /dev/null +++ b/pkg/utils/auth/multitenancy_test.go @@ -0,0 +1,149 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth + +import ( + "context" + "encoding/base64" + "testing" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestNewMultitenancyConfig(t *testing.T) { + clientset := fake.NewSimpleClientset() + uid := "test-uid" + mc := NewMultitenancyConfig(clientset, uid) + + if mc.owner_uid != uid { + t.Errorf("expected owner_uid to be %s, got %s", uid, mc.owner_uid) + } +} + +func TestUpdateCache(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + mc.UpdateCache("user1") + if len(mc.usersCache) != 1 { + t.Errorf("expected usersCache length to be 1, got %d", len(mc.usersCache)) + } +} + +func TestClearCache(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + mc.UpdateCache("user1") + mc.ClearCache() + if len(mc.usersCache) != 0 { + t.Errorf("expected usersCache length to be 0, got %d", len(mc.usersCache)) + } +} + +func TestUserExists(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user1", + Namespace: ServicesNamespace, + }, + } + clientset.CoreV1().Secrets(ServicesNamespace).Create(context.TODO(), secret, metav1.CreateOptions{}) + + exists := mc.UserExists("user1@egi.eu") + if !exists { + t.Errorf("expected user1 to exist") + } +} + +func TestCreateSecretForOIDC(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + err := mc.CreateSecretForOIDC("user1@egi.eu", "secret-key") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + secret, err := clientset.CoreV1().Secrets(ServicesNamespace).Get(context.TODO(), "user1", metav1.GetOptions{}) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if secret.StringData["secretKey"] != "secret-key" { + t.Errorf("expected secretKey to be 'secret-key', got %s", secret.StringData["secretKey"]) + } +} + +func TestGetUserCredentials(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user1", + Namespace: ServicesNamespace, + }, + Data: map[string][]byte{ + "accessKey": []byte("access-key"), + "secretKey": []byte("secret-key"), + }, + } + clientset.CoreV1().Secrets(ServicesNamespace).Create(context.TODO(), secret, metav1.CreateOptions{}) + + accessKey, secretKey, err := mc.GetUserCredentials("user1@egi.eu") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if accessKey != "access-key" { + t.Errorf("expected accessKey to be 'access-key', got %s", accessKey) + } + + if secretKey != "secret-key" { + t.Errorf("expected secretKey to be 'secret-key', got %s", secretKey) + } +} + +func TestGenerateRandomKey(t *testing.T) { + key, err := GenerateRandomKey(32) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + dkey, _ := base64.RawURLEncoding.DecodeString(key) + if len(dkey) != 32 { + t.Errorf("expected key length to be 32, got %d", len(key)) + } +} + +func TestCheckUsersInCache(t *testing.T) { + clientset := fake.NewSimpleClientset() + mc := NewMultitenancyConfig(clientset, "test-uid") + + mc.UpdateCache("user1") + mc.UpdateCache("user2") + + notFoundUsers := mc.CheckUsersInCache([]string{"user1", "user3"}) + if len(notFoundUsers) != 1 { + t.Errorf("expected notFoundUsers length to be 1, got %d", len(notFoundUsers)) + } +} diff --git a/pkg/utils/auth/oidc.go b/pkg/utils/auth/oidc.go index fb60575b..40dbd54d 100644 --- a/pkg/utils/auth/oidc.go +++ b/pkg/utils/auth/oidc.go @@ -76,8 +76,11 @@ func NewOIDCManager(issuer string, subject string, groups []string) (*oidcManage } // getIODCMiddleware returns the Gin's handler middleware to validate OIDC-based auth -func getOIDCMiddleware(kubeClientset *kubernetes.Clientset, minIOAdminClient *utils.MinIOAdminClient, issuer string, subject string, groups []string) gin.HandlerFunc { +func getOIDCMiddleware(kubeClientset kubernetes.Interface, minIOAdminClient *utils.MinIOAdminClient, issuer string, subject string, groups []string, oidcConfig *oidc.Config) gin.HandlerFunc { oidcManager, err := NewOIDCManager(issuer, subject, groups) + if oidcConfig != nil { + oidcManager.config = oidcConfig + } if err != nil { return func(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) diff --git a/pkg/utils/auth/oidc_test.go b/pkg/utils/auth/oidc_test.go new file mode 100644 index 00000000..142e520f --- /dev/null +++ b/pkg/utils/auth/oidc_test.go @@ -0,0 +1,219 @@ +/* +Copyright (C) GRyCAP - I3M - UPV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/grycap/oscar/v3/pkg/types" + "github.com/grycap/oscar/v3/pkg/utils" + "k8s.io/client-go/kubernetes/fake" +) + +func TestNewOIDCManager(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path == "/.well-known/openid-configuration" { + rw.Write([]byte(`{"issuer": "http://` + hreq.Host + `"}`)) + } + })) + + issuer := server.URL + subject := "test-subject" + groups := []string{"group1", "group2"} + + oidcManager, err := NewOIDCManager(issuer, subject, groups) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if oidcManager == nil { + t.Errorf("expected oidcManager to be non-nil") + } +} + +func TestGetUserInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + fmt.Println(hreq.URL.Path) + rw.Header().Set("Content-Type", "application/json") + if hreq.URL.Path == "/.well-known/openid-configuration" { + rw.Write([]byte(`{"issuer": "http://` + hreq.Host + `", "userinfo_endpoint": "http://` + hreq.Host + `/userinfo"}`)) + } else if hreq.URL.Path == "/userinfo" { + rw.Write([]byte(`{"sub": "test-subject", "eduperson_entitlement": ["urn:mace:egi.eu:group:group1"]}`)) + } + })) + + issuer := server.URL + subject := "test-subject" + groups := []string{"group1", "group2"} + + oidcManager, err := NewOIDCManager(issuer, subject, groups) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + rawToken := "test-token" + ui, err := oidcManager.GetUserInfo(rawToken) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if ui.Subject != "test-subject" { + t.Errorf("expected subject to be %v, got %v", "test-subject", ui.Subject) + } + if len(ui.Groups) != 1 || ui.Groups[0] != "group1" { + t.Errorf("expected groups to be %v, got %v", []string{"group1"}, ui.Groups) + } +} + +func TestIsAuthorised(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + rw.Header().Set("Content-Type", "application/json") + if hreq.URL.Path == "/.well-known/openid-configuration" { + rw.Write([]byte(`{"issuer": "http://` + hreq.Host + `", "userinfo_endpoint": "http://` + hreq.Host + `/userinfo"}`)) + } else if hreq.URL.Path == "/userinfo" { + rw.Write([]byte(`{"sub": "user1@egi.eu", "eduperson_entitlement": ["urn:mace:egi.eu:group:group1"]}`)) + } + })) + + issuer := server.URL + subject := "user1@egi.eu" + groups := []string{"group1", "group2"} + + oidcManager, err := NewOIDCManager(issuer, subject, groups) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + rawToken := GetToken(issuer, subject) + oidcManager.config.InsecureSkipSignatureCheck = true + + if !oidcManager.IsAuthorised(rawToken) { + t.Errorf("expected token to be authorised") + } + + resg1, err2 := oidcManager.UserHasVO(rawToken, "group1") + if err2 != nil { + t.Errorf("expected no error, got %v", err) + } + if !resg1 { + t.Errorf("expected user to have VO") + } + resg2, err3 := oidcManager.UserHasVO(rawToken, "group2") + if err3 != nil { + t.Errorf("expected no error, got %v", err) + } + if resg2 { + t.Errorf("expected user not to have VO") + } + + uid, _ := oidcManager.GetUID(rawToken) + if uid != subject { + t.Errorf("expected uid to be %v, got %v", subject, uid) + } +} + +func TestGetOIDCMiddleware(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, hreq *http.Request) { + if hreq.URL.Path == "/.well-known/openid-configuration" { + rw.Write([]byte(`{"issuer": "http://` + hreq.Host + `", "userinfo_endpoint": "http://` + hreq.Host + `/userinfo"}`)) + } else if hreq.URL.Path == "/userinfo" { + rw.Write([]byte(`{"sub": "user@egi.eu", "eduperson_entitlement": ["urn:mace:egi.eu:group:group1"]}`)) + } else if hreq.URL.Path == "/minio/admin/v3/info" { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"Mode": "local", "Region": "us-east-1"}`)) + } else { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"status": "success"}`)) + } + })) + + kubeClientset := fake.NewSimpleClientset() + cfg := types.Config{ + MinIOProvider: &types.MinIOProvider{ + Endpoint: server.URL, + Verify: false, + }, + } + minIOAdminClient, _ := utils.MakeMinIOAdminClient(&cfg) + issuer := server.URL + subject := "user@egi.eu" + groups := []string{"group1", "group2"} + + oidcConfig := &oidc.Config{ + InsecureSkipSignatureCheck: true, + SkipClientIDCheck: true, + } + middleware := getOIDCMiddleware(kubeClientset, minIOAdminClient, issuer, subject, groups, oidcConfig) + if middleware == nil { + t.Errorf("expected middleware to be non-nil") + } + + scenarios := []struct { + token string + code int + name string + }{ + { + name: "invalid-token", + token: "invalid-token", + code: http.StatusUnauthorized, + }, + { + name: "valid-token", + token: GetToken(issuer, subject), + code: http.StatusOK, + }, + } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + // Create a new Gin context + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Test the middleware with an invalid token + c.Request = &http.Request{ + Header: http.Header{ + "Authorization": []string{"Bearer " + s.token}, + }, + } + middleware(c) + if c.Writer.Status() != s.code { + t.Errorf("expected status to be %v, got %v", s.code, c.Writer.Status()) + } + }) + } +} + +func GetToken(issuer string, subject string) string { + claims := jwt.MapClaims{ + "iss": issuer, + "sub": subject, + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) + rawToken, _ := token.SignedString(privateKey) + return rawToken +}