diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 0500e70f0..89d0a7974 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -60,6 +60,10 @@ const ( // namespaces AllNamespacesPath = PathPrefix + "/namespaces" + // secrets + AllSecretsPath = PathPrefix + "/secrets/:" + NamespacePathParam + SecretsByNamePath = AllSecretsPath + "/:" + ResourceNamePathParam + // swagger SwaggerPath = PathPrefix + "/swagger/*any" SwaggerDocPath = PathPrefix + "/swagger/doc.json" @@ -112,6 +116,13 @@ func (a *App) Routes() http.Handler { // namespaces router.GET(AllNamespacesPath, a.GetNamespacesHandler) + // secrets + router.GET(AllSecretsPath, a.GetSecretsHandler) + router.POST(AllSecretsPath, a.CreateSecretHandler) + router.GET(SecretsByNamePath, a.GetSecretHandler) + router.PUT(SecretsByNamePath, a.UpdateSecretHandler) + router.DELETE(SecretsByNamePath, a.DeleteSecretHandler) + // workspaces router.GET(AllWorkspacesPath, a.GetAllWorkspacesHandler) router.GET(WorkspacesByNamespacePath, a.GetWorkspacesByNamespaceHandler) diff --git a/workspaces/backend/api/secrets_handler.go b/workspaces/backend/api/secrets_handler.go new file mode 100644 index 000000000..48750ce50 --- /dev/null +++ b/workspaces/backend/api/secrets_handler.go @@ -0,0 +1,353 @@ +/* +Copyright 2024. + +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 api + +import ( + "errors" + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/secrets" + repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/secrets" +) + +type SecretEnvelope Envelope[*models.SecretUpdate] +type SecretListEnvelope Envelope[[]models.SecretListItem] +type SecretCreateEnvelope Envelope[*models.SecretCreate] + +// getSecretAuthPolicies creates authorization policies for secret operations. +func getSecretAuthPolicies(verb auth.ResourceVerb, namespace string) []*auth.ResourcePolicy { + return []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + verb, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + }, + ), + } +} + +// GetSecretsHandler returns a list of all secrets in a namespace. +// +// @Summary Returns a list of all secrets in a namespace +// @Description Provides a list of all secrets that the user has access to in the specified namespace +// @Tags secrets +// @ID listSecrets +// @Produce application/json +// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace) +// @Success 200 {object} SecretListEnvelope "Successful secrets response" +// @Failure 401 {object} ErrorEnvelope "Unauthorized" +// @Failure 403 {object} ErrorEnvelope "Forbidden" +// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error." +// @Failure 500 {object} ErrorEnvelope "Internal server error" +// @Router /secrets/{namespace} [get] +func (a *App) GetSecretsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // =========================== AUTH =========================== + authPolicies := getSecretAuthPolicies(auth.ResourceVerbList, namespace) + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + secretList, err := a.repositories.Secret.GetSecrets(r.Context(), namespace) + if err != nil { + a.serverErrorResponse(w, r, err) + return + } + + responseEnvelope := &SecretListEnvelope{Data: secretList} + a.dataResponse(w, r, responseEnvelope) +} + +// GetSecretHandler returns a specific secret by name and namespace. +// +// @Summary Returns a specific secret +// @Description Provides details of a specific secret by name and namespace +// @Tags secrets +// @ID getSecret +// @Produce application/json +// @Param namespace path string true "Namespace name" +// @Param name path string true "Secret name" extensions(x-example=my-secret) +// @Success 200 {object} SecretEnvelope "Successful secret response" +// @Failure 401 {object} ErrorEnvelope "Unauthorized" +// @Failure 403 {object} ErrorEnvelope "Forbidden" +// @Failure 404 {object} ErrorEnvelope "Secret not found" +// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error." +// @Failure 500 {object} ErrorEnvelope "Internal server error" +// @Router /secrets/{namespace}/{name} [get] +func (a *App) GetSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + secretName := ps.ByName(ResourceNamePathParam) + + // validate path parameters + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // =========================== AUTH =========================== + authPolicies := getSecretAuthPolicies(auth.ResourceVerbGet, namespace) + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + secret, err := a.repositories.Secret.GetSecret(r.Context(), namespace, secretName) + if err != nil { + // Check if it's a not found error + if errors.Is(err, repository.ErrSecretNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + responseEnvelope := &SecretEnvelope{Data: secret} + a.dataResponse(w, r, responseEnvelope) +} + +// CreateSecretHandler creates a new secret. +// +// @Summary Creates a new secret +// @Description Creates a new secret in the specified namespace +// @Tags secrets +// @ID createSecret +// @Accept json +// @Produce json +// @Param namespace path string true "Namespace name" +// @Param secret body SecretCreateEnvelope true "Secret creation request" +// @Success 201 {object} SecretCreateEnvelope "Secret created successfully" +// @Failure 400 {object} ErrorEnvelope "Bad request" +// @Failure 401 {object} ErrorEnvelope "Unauthorized" +// @Failure 403 {object} ErrorEnvelope "Forbidden" +// @Failure 409 {object} ErrorEnvelope "Secret already exists" +// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large." +// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct." +// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error." +// @Failure 500 {object} ErrorEnvelope "Internal server error" +// @Router /secrets/{namespace} [post] +func (a *App) CreateSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // =========================== AUTH =========================== + authPolicies := getSecretAuthPolicies(auth.ResourceVerbCreate, namespace) + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // Parse request body + bodyEnvelope := &SecretCreateEnvelope{} + err := a.DecodeJSON(r, bodyEnvelope) + if err != nil { + if a.IsMaxBytesError(err) { + a.requestEntityTooLargeResponse(w, r, err) + return + } + a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err)) + return + } + + // Validate the request body + dataPath := field.NewPath("data") + if bodyEnvelope.Data == nil { + valErrs := field.ErrorList{field.Required(dataPath, "data is required")} + a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil) + return + } + valErrs = bodyEnvelope.Data.Validate(dataPath) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil) + return + } + + secret, err := a.repositories.Secret.CreateSecret(r.Context(), namespace, bodyEnvelope.Data) + if err != nil { + // Check if it's an already exists error + if errors.Is(err, repository.ErrSecretAlreadyExists) { + a.conflictResponse(w, r, err) + return + } + a.serverErrorResponse(w, r, err) + return + } + + responseEnvelope := &SecretCreateEnvelope{Data: secret} + location := fmt.Sprintf("/secrets/%s/%s", namespace, secret.Name) + a.createdResponse(w, r, responseEnvelope, location) +} + +// UpdateSecretHandler updates an existing secret. +// +// @Summary Updates an existing secret +// @Description Updates an existing secret in the specified namespace +// @Tags secrets +// @ID updateSecret +// @Accept json +// @Produce json +// @Param namespace path string true "Namespace name" +// @Param name path string true "Secret name" +// @Param secret body models.SecretUpdate true "Secret update request" +// @Success 200 {object} SecretEnvelope "Secret updated successfully" +// @Failure 400 {object} ErrorEnvelope "Bad request" +// @Failure 401 {object} ErrorEnvelope "Unauthorized" +// @Failure 403 {object} ErrorEnvelope "Forbidden" +// @Failure 404 {object} ErrorEnvelope "Secret not found" +// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large." +// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct." +// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error." +// @Failure 500 {object} ErrorEnvelope "Internal server error" +// @Router /secrets/{namespace}/{name} [put] +func (a *App) UpdateSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + secretName := ps.ByName(ResourceNamePathParam) + + // validate path parameters + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // =========================== AUTH =========================== + authPolicies := getSecretAuthPolicies(auth.ResourceVerbUpdate, namespace) + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // Parse request body + bodyEnvelope := &SecretEnvelope{} + err := a.DecodeJSON(r, bodyEnvelope) + if err != nil { + if a.IsMaxBytesError(err) { + a.requestEntityTooLargeResponse(w, r, err) + return + } + a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err)) + return + } + + // Validate the request body + dataPath := field.NewPath("data") + if bodyEnvelope.Data == nil { + valErrs := field.ErrorList{field.Required(dataPath, "data is required")} + a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil) + return + } + valErrs = bodyEnvelope.Data.Validate(dataPath) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil) + return + } + + secret, err := a.repositories.Secret.UpdateSecret(r.Context(), namespace, secretName, bodyEnvelope.Data) + if err != nil { + // Check if it's a not found error + if errors.Is(err, repository.ErrSecretNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + responseEnvelope := &SecretEnvelope{Data: secret} + a.dataResponse(w, r, responseEnvelope) +} + +// DeleteSecretHandler deletes a secret. +// +// @Summary Deletes a secret +// @Description Deletes a secret from the specified namespace +// @Tags secrets +// @ID deleteSecret +// @Accept json +// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace) +// @Param name path string true "Secret name" extensions(x-example=my-secret) +// @Success 204 "No Content" +// @Failure 401 {object} ErrorEnvelope "Unauthorized" +// @Failure 403 {object} ErrorEnvelope "Forbidden" +// @Failure 404 {object} ErrorEnvelope "Secret not found" +// @Failure 500 {object} ErrorEnvelope "Internal server error" +// @Router /secrets/{namespace}/{name} [delete] +func (a *App) DeleteSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + secretName := ps.ByName(ResourceNamePathParam) + + // validate path parameters + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // =========================== AUTH =========================== + authPolicies := getSecretAuthPolicies(auth.ResourceVerbDelete, namespace) + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + err := a.repositories.Secret.DeleteSecret(r.Context(), namespace, secretName) + if err != nil { + // Check if it's a not found error + if errors.Is(err, repository.ErrSecretNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + a.deletedResponse(w, r) +} diff --git a/workspaces/backend/api/secrets_handler_test.go b/workspaces/backend/api/secrets_handler_test.go new file mode 100644 index 000000000..64623c193 --- /dev/null +++ b/workspaces/backend/api/secrets_handler_test.go @@ -0,0 +1,460 @@ +/* +Copyright 2024. + +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 api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/julienschmidt/httprouter" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/secrets" +) + +// TODO: Fix tests to not rely on mocked data once repository implementation is ready +var _ = Describe("Secrets Handler", func() { + + // NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests. + // therefore, we run them using the `Serial` Ginkgo decorators. + Context("when secrets exist", Serial, func() { + + const secretName1 = "test-secret-1" + const secretName2 = "test-secret-2" + var namespaceName string + + BeforeEach(func() { + namespaceName = "secrets-test-ns-" + fmt.Sprintf("%d", time.Now().UnixNano()) + By("creating test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + + By("creating Secret 1") + secret1 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName1, + Namespace: namespaceName, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "username": []byte("testuser"), + "password": []byte("testpass"), + }, + } + Expect(k8sClient.Create(ctx, secret1)).To(Succeed()) + + By("creating Secret 2") + secret2 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName2, + Namespace: namespaceName, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "api-key": []byte("test-api-key"), + }, + } + Expect(k8sClient.Create(ctx, secret2)).To(Succeed()) + }) + + AfterEach(func() { + By("deleting test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + It("should retrieve all secrets in namespace successfully", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, "/api/v1/secrets/"+namespaceName, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing GetSecretsHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + } + rr := httptest.NewRecorder() + a.GetSecretsHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to SecretListEnvelope") + var response SecretListEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("ensuring the response contains the expected secrets") + // NOTE: Currently returns mock data as stub implementation + Expect(response.Data).To(HaveLen(3)) + }) + + It("should return 200 for mock secret", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, "/api/v1/secrets/"+namespaceName+"/database-credentials", http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing GetSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "database-credentials"}, + } + rr := httptest.NewRecorder() + a.GetSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 404 for non-existent secret", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, "/api/v1/secrets/"+namespaceName+"/non-existent", http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing GetSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "non-existent"}, + } + rr := httptest.NewRecorder() + a.GetSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) + }) + }) + + Context("when creating secrets", Serial, func() { + var namespaceName string + + BeforeEach(func() { + namespaceName = "secrets-create-test-ns-" + fmt.Sprintf("%d", time.Now().UnixNano()) + By("creating test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + }) + + AfterEach(func() { + By("deleting test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + It("should return 201 for create secret (mock implementation)", func() { + By("creating the HTTP request body") + createReq := models.NewSecretCreate( + "test-secret", + "Opaque", + false, + models.SecretData{ + "username": {Base64: ptr.To("dGVzdHVzZXI=")}, // base64 for "testuser" + "password": {Base64: ptr.To("dGVzdHBhc3M=")}, // base64 for "testpass" + }, + ) + bodyEnvelope := SecretCreateEnvelope{Data: &createReq} + reqBody, err := json.Marshal(bodyEnvelope) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, "/api/v1/secrets/"+namespaceName, bytes.NewBuffer(reqBody)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + } + rr := httptest.NewRecorder() + a.CreateSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 400 for invalid request body", func() { + By("creating the HTTP request with invalid JSON") + req, err := http.NewRequest(http.MethodPost, "/api/v1/secrets/"+namespaceName, bytes.NewBufferString("invalid json")) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + } + rr := httptest.NewRecorder() + a.CreateSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 422 for missing name", func() { + By("creating the HTTP request body without name") + createReq := models.NewSecretCreate( + "", // empty name to trigger validation error + "Opaque", + false, + models.SecretData{ + "username": {Base64: ptr.To("dGVzdHVzZXI=")}, // base64 for "testuser" + }, + ) + bodyEnvelope := SecretCreateEnvelope{Data: &createReq} + reqBody, err := json.Marshal(bodyEnvelope) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, "/api/v1/secrets/"+namespaceName, bytes.NewBuffer(reqBody)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + } + rr := httptest.NewRecorder() + a.CreateSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + }) + }) + + Context("when updating secrets", Serial, func() { + var namespaceName string + + BeforeEach(func() { + namespaceName = "secrets-update-test-ns-" + fmt.Sprintf("%d", time.Now().UnixNano()) + By("creating test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + }) + + AfterEach(func() { + By("deleting test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + It("should return 200 for update secret (mock implementation)", func() { + By("creating the HTTP request body") + updateReq := models.NewSecretUpdate( + "Opaque", + false, + models.SecretData{ + "username": {Base64: ptr.To("dXBkYXRlZHVzZXI=")}, // base64 for "updateduser" + "password": {Base64: ptr.To("dXBkYXRlZHBhc3M=")}, // base64 for "updatedpass" + }, + ) + bodyEnvelope := SecretEnvelope{Data: &updateReq} + reqBody, err := json.Marshal(bodyEnvelope) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPut, "/api/v1/secrets/"+namespaceName+"/database-credentials", bytes.NewBuffer(reqBody)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing UpdateSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "database-credentials"}, + } + rr := httptest.NewRecorder() + a.UpdateSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 404 for update non-existent secret", func() { + By("creating the HTTP request body") + updateReq := models.NewSecretUpdate( + "Opaque", + false, + models.SecretData{ + "username": {Base64: ptr.To("dXBkYXRlZHVzZXI=")}, // base64 for "updateduser" + "password": {Base64: ptr.To("dXBkYXRlZHBhc3M=")}, // base64 for "updatedpass" + }, + ) + bodyEnvelope := SecretEnvelope{Data: &updateReq} + reqBody, err := json.Marshal(bodyEnvelope) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPut, "/api/v1/secrets/"+namespaceName+"/non-existent", bytes.NewBuffer(reqBody)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing UpdateSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "non-existent"}, + } + rr := httptest.NewRecorder() + a.UpdateSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) + }) + }) + + Context("when deleting secrets", Serial, func() { + var namespaceName string + + BeforeEach(func() { + namespaceName = "secrets-delete-test-ns-" + fmt.Sprintf("%d", time.Now().UnixNano()) + By("creating test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + }) + + AfterEach(func() { + By("deleting test namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + }) + + It("should return 204 for delete secret (mock implementation)", func() { + By("creating the HTTP request") + // Use a mock secret name that exists in the mock data + req, err := http.NewRequest(http.MethodDelete, "/api/v1/secrets/"+namespaceName+"/database-credentials", http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing DeleteSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "database-credentials"}, + } + rr := httptest.NewRecorder() + a.DeleteSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNoContent), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 404 for delete non-existent secret", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodDelete, "/api/v1/secrets/"+namespaceName+"/non-existent", http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + + By("executing DeleteSecretHandler") + ps := httprouter.Params{ + {Key: NamespacePathParam, Value: namespaceName}, + {Key: ResourceNamePathParam, Value: "non-existent"}, + } + rr := httptest.NewRecorder() + a.DeleteSecretHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) + }) + }) +}) diff --git a/workspaces/backend/internal/helper/validation.go b/workspaces/backend/internal/helper/validation.go index 77f22a6d0..ad3e341dd 100644 --- a/workspaces/backend/internal/helper/validation.go +++ b/workspaces/backend/internal/helper/validation.go @@ -17,6 +17,7 @@ limitations under the License. package helper import ( + "encoding/base64" "errors" "strings" @@ -92,3 +93,40 @@ func ValidateFieldIsDNS1123Label(path *field.Path, value string) field.ErrorList return errs } + +// ValidateFieldIsConfigMapKey validates a field contains a valid key name. +// USED FOR: +// - keys of: Secrets, ConfigMaps +func ValidateFieldIsConfigMapKey(path *field.Path, value string) field.ErrorList { + var errs field.ErrorList + + if value == "" { + errs = append(errs, field.Required(path, "")) + } else { + failures := validation.IsConfigMapKey(value) + if len(failures) > 0 { + errs = append(errs, field.Invalid(path, value, strings.Join(failures, "; "))) + } + } + + return errs +} + +// ValidateFieldIsBase64Encoded validates a field value can be base64 decoded. +// USED FOR: +// - values of: Secrets +func ValidateFieldIsBase64Encoded(path *field.Path, value string) field.ErrorList { + var errs field.ErrorList + + if value == "" { + errs = append(errs, field.Required(path, "")) + } else { + // Use standard Go library to validate base64 encoding + _, err := base64.StdEncoding.DecodeString(value) + if err != nil { + errs = append(errs, field.Invalid(path, value, "must be valid base64 encoded string")) + } + } + + return errs +} diff --git a/workspaces/backend/internal/models/common/funcs.go b/workspaces/backend/internal/models/common/funcs.go new file mode 100644 index 000000000..9ba4c8e3e --- /dev/null +++ b/workspaces/backend/internal/models/common/funcs.go @@ -0,0 +1,51 @@ +/* +Copyright 2024. + +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 common + +import ( + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewAuditFromObjectMeta creates an Audit instance from Kubernetes ObjectMeta. +// It extracts audit information from annotations, falling back to Kubernetes +// creation timestamp when annotations are not available. +func NewAuditFromObjectMeta(objectMeta *metav1.ObjectMeta) Audit { + audit := Audit{ + CreatedAt: objectMeta.CreationTimestamp.Time, // Default to Kubernetes creation time + CreatedBy: objectMeta.Annotations["notebooks.kubeflow.org/created-by"], + UpdatedAt: objectMeta.CreationTimestamp.Time, // Default to creation time + UpdatedBy: objectMeta.Annotations["notebooks.kubeflow.org/updated-by"], + } + + // Parse created timestamp if available + if createTimeStr, exists := objectMeta.Annotations["notebooks.kubeflow.org/created-at"]; exists { + if createTime, err := time.Parse(time.RFC3339, createTimeStr); err == nil { + audit.CreatedAt = createTime + } + } + + // Parse updated timestamp if available + if updateTimeStr, exists := objectMeta.Annotations["notebooks.kubeflow.org/updated-at"]; exists { + if updateTime, err := time.Parse(time.RFC3339, updateTimeStr); err == nil { + audit.UpdatedAt = updateTime + } + } + + return audit +} diff --git a/workspaces/backend/internal/models/common/types.go b/workspaces/backend/internal/models/common/types.go new file mode 100644 index 000000000..5e67f6c92 --- /dev/null +++ b/workspaces/backend/internal/models/common/types.go @@ -0,0 +1,29 @@ +/* +Copyright 2024. + +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 common + +import ( + "time" +) + +// Audit represents audit information for resources +type Audit struct { + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + UpdatedAt time.Time `json:"updatedAt"` + UpdatedBy string `json:"updatedBy"` +} diff --git a/workspaces/backend/internal/models/secrets/funcs.go b/workspaces/backend/internal/models/secrets/funcs.go new file mode 100644 index 000000000..84bf352b4 --- /dev/null +++ b/workspaces/backend/internal/models/secrets/funcs.go @@ -0,0 +1,54 @@ +/* +Copyright 2024. + +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 secrets + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common" +) + +// NewSecretListItemFromSecret creates a new SecretListItem model from a Kubernetes Secret object. +// TODO: Extract mounts from workspace references (would need to query workspaces) and pass in as an argument. +func NewSecretListItemFromSecret(secret *corev1.Secret) SecretListItem { + // Convert the secret data to the API format (never return actual values) + contents := make(SecretData) + for key := range secret.Data { + contents[key] = SecretValue{} // Empty value - never return actual data + } + + // Extract audit information from annotations + audit := common.NewAuditFromObjectMeta(&secret.ObjectMeta) + + // Check labels for permissions + canUpdate := secret.Labels["notebooks.kubeflow.org/can-update"] == "true" + canMount := secret.Labels["notebooks.kubeflow.org/can-mount"] == "true" + + // TODO: Extract mounts from workspace references (would need to query workspaces) + mounts := []SecretMount{} + + return SecretListItem{ + Name: secret.Name, + Type: string(secret.Type), + Immutable: ptr.Deref(secret.Immutable, false), + CanUpdate: canUpdate, + CanMount: canMount, + Mounts: mounts, + Audit: audit, + } +} diff --git a/workspaces/backend/internal/models/secrets/funcs_write.go b/workspaces/backend/internal/models/secrets/funcs_write.go new file mode 100644 index 000000000..d64c3db31 --- /dev/null +++ b/workspaces/backend/internal/models/secrets/funcs_write.go @@ -0,0 +1,133 @@ +/* +Copyright 2024. + +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 secrets + +import ( + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +// secretDataFromKubernetesSecret converts Kubernetes secret.Data to SecretData. +// Returns empty SecretValue for each key to never expose actual secret values. +func secretDataFromKubernetesSecret(data map[string][]byte) SecretData { + contents := make(SecretData) + for key := range data { + contents[key] = SecretValue{} // Empty value - never return actual data + } + return contents +} + +// NewSecretCreateModelFromSecret creates a SecretCreate model from a Kubernetes Secret object. +func NewSecretCreateModelFromSecret(secret *corev1.Secret) SecretCreate { + contents := secretDataFromKubernetesSecret(secret.Data) + + return SecretCreate{ + Name: secret.Name, + secretBase: secretBase{ + Type: string(secret.Type), + Immutable: ptr.Deref(secret.Immutable, false), + Contents: contents, + }, + } +} + +// NewSecretUpdateModelFromSecret creates a SecretUpdate model from a Kubernetes Secret object. +func NewSecretUpdateModelFromSecret(secret *corev1.Secret) SecretUpdate { + contents := secretDataFromKubernetesSecret(secret.Data) + + return SecretUpdate{ + secretBase: secretBase{ + Type: string(secret.Type), + Immutable: ptr.Deref(secret.Immutable, false), + Contents: contents, + }, + } +} + +// ToKubernetesSecret converts a SecretCreate model to a Kubernetes Secret object. +func (s *SecretCreate) ToKubernetesSecret(namespace string, userEmail string) *corev1.Secret { + // Convert SecretValue back to []byte for Kubernetes + data := make(map[string][]byte) + for key, value := range s.Contents { + if value.Base64 != nil { + // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) + // Empty string is a valid value, so we include it + data[key] = []byte(*value.Base64) + } + } + + now := time.Now().Format(time.RFC3339) + + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.Name, + Namespace: namespace, + Labels: map[string]string{ + "notebooks.kubeflow.org/can-mount": "true", + "notebooks.kubeflow.org/can-update": "true", + }, + Annotations: map[string]string{ + "notebooks.kubeflow.org/created-by": userEmail, + "notebooks.kubeflow.org/created-at": now, + "notebooks.kubeflow.org/updated-by": userEmail, + "notebooks.kubeflow.org/updated-at": now, + }, + }, + Type: corev1.SecretType(s.Type), + Data: data, + Immutable: &s.Immutable, + } +} + +// ToKubernetesSecret converts a SecretUpdate model to a Kubernetes Secret object. +// TODO: implement logic to merge SecretUpdate with currentSecret. +func (s *SecretUpdate) ToKubernetesSecret(currentSecret *corev1.Secret, userEmail string) *corev1.Secret { + // Convert SecretValue back to []byte for Kubernetes + data := make(map[string][]byte) + for key, value := range s.Contents { + if value.Base64 != nil { + // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) + // Empty string is a valid value, so we include it + data[key] = []byte(*value.Base64) + } + } + + now := time.Now().Format(time.RFC3339) + + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: currentSecret.Name, + Namespace: currentSecret.Namespace, + Labels: map[string]string{ + "notebooks.kubeflow.org/can-mount": "true", + "notebooks.kubeflow.org/can-update": "true", + }, + Annotations: map[string]string{ + "notebooks.kubeflow.org/created-by": currentSecret.Annotations["notebooks.kubeflow.org/created-by"], + "notebooks.kubeflow.org/created-at": currentSecret.Annotations["notebooks.kubeflow.org/created-at"], + "notebooks.kubeflow.org/updated-by": userEmail, + "notebooks.kubeflow.org/updated-at": now, + }, + }, + Type: corev1.SecretType(s.Type), + Data: data, + Immutable: &s.Immutable, + } +} diff --git a/workspaces/backend/internal/models/secrets/types.go b/workspaces/backend/internal/models/secrets/types.go new file mode 100644 index 000000000..f0afd601e --- /dev/null +++ b/workspaces/backend/internal/models/secrets/types.go @@ -0,0 +1,39 @@ +/* +Copyright 2024. + +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 secrets + +import ( + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common" +) + +// SecretListItem represents a secret in the list response with additional metadata +type SecretListItem struct { + Name string `json:"name"` + Type string `json:"type"` + Immutable bool `json:"immutable"` + CanUpdate bool `json:"canUpdate"` + CanMount bool `json:"canMount"` + Mounts []SecretMount `json:"mounts,omitempty"` + Audit common.Audit `json:"audit"` +} + +// SecretMount represents where a secret is mounted +type SecretMount struct { + Group string `json:"group"` + Kind string `json:"kind"` + Name string `json:"name"` +} diff --git a/workspaces/backend/internal/models/secrets/types_write.go b/workspaces/backend/internal/models/secrets/types_write.go new file mode 100644 index 000000000..c173c945d --- /dev/null +++ b/workspaces/backend/internal/models/secrets/types_write.go @@ -0,0 +1,127 @@ +/* +Copyright 2024. + +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 secrets + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" +) + +// SecretValue represents a secret value with base64 encoding +type SecretValue struct { + Base64 *string `json:"base64,omitempty"` +} + +// SecretData represents a map of secret key-value pairs +type SecretData map[string]SecretValue + +// secretBase represents the common fields shared between SecretCreate and SecretUpdate +type secretBase struct { + Type string `json:"type"` + Immutable bool `json:"immutable"` + Contents SecretData `json:"contents"` +} + +// SecretCreate is used to create a new secret. +type SecretCreate struct { + Name string `json:"name"` + secretBase +} + +// SecretUpdate represents the request body for updating a secret +type SecretUpdate struct { + secretBase +} + +// NewSecretCreate creates a new SecretCreate with the specified fields. +func NewSecretCreate(name, secretType string, immutable bool, contents SecretData) SecretCreate { + return SecretCreate{ + Name: name, + secretBase: secretBase{ + Type: secretType, + Immutable: immutable, + Contents: contents, + }, + } +} + +// NewSecretUpdate creates a new SecretUpdate with the specified fields. +func NewSecretUpdate(secretType string, immutable bool, contents SecretData) SecretUpdate { + return SecretUpdate{ + secretBase: secretBase{ + Type: secretType, + Immutable: immutable, + Contents: contents, + }, + } +} + +// Validate validates the SecretCreate struct. +// NOTE: we only do basic validation, more complex validation is done by Kubernetes when attempting to create the secret. +func (s *SecretCreate) Validate(prefix *field.Path) []*field.Error { + var errs []*field.Error + + // validate the secret name + namePath := prefix.Child("name") + errs = append(errs, helper.ValidateFieldIsDNS1123Subdomain(namePath, s.Name)...) + + // validate common fields (type and contents) + errs = append(errs, s.secretBase.validateBase(prefix)...) + + return errs +} + +// Validate validates the SecretUpdate struct. +// NOTE: we only do basic validation, more complex validation is done by Kubernetes when attempting to update the secret. +func (s *SecretUpdate) Validate(prefix *field.Path) []*field.Error { + // validate common fields (type and contents) + return s.secretBase.validateBase(prefix) +} + +// ValidateContents validates the contents map of a secret. +func (s *SecretData) Validate(prefix *field.Path) []*field.Error { + var errs []*field.Error + + if s == nil { + return errs // nil is valid for optional fields + } + + for key, value := range *s { + keyPath := prefix.Key(key) + errs = append(errs, helper.ValidateFieldIsConfigMapKey(keyPath, key)...) + + // TODO: determine proper way to validate secret values + // Only validate base64 if it's present + if base64Value := ptr.Deref(value.Base64, ""); base64Value != "" { + errs = append(errs, helper.ValidateFieldIsBase64Encoded(keyPath, base64Value)...) + } + } + + return errs +} + +// validateBase validates the common fields of a secret (contents). +func (sb *secretBase) validateBase(prefix *field.Path) []*field.Error { + var errs []*field.Error + + contentsPath := prefix.Child("contents") + errs = append(errs, sb.Contents.Validate(contentsPath)...) + + return errs +} diff --git a/workspaces/backend/internal/repositories/repositories.go b/workspaces/backend/internal/repositories/repositories.go index 6e7429e2f..76161ad51 100644 --- a/workspaces/backend/internal/repositories/repositories.go +++ b/workspaces/backend/internal/repositories/repositories.go @@ -21,6 +21,7 @@ import ( "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/health_check" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/namespaces" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/secrets" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" ) @@ -29,6 +30,7 @@ import ( type Repositories struct { HealthCheck *health_check.HealthCheckRepository Namespace *namespaces.NamespaceRepository + Secret *secrets.SecretRepository Workspace *workspaces.WorkspaceRepository WorkspaceKind *workspacekinds.WorkspaceKindRepository } @@ -38,6 +40,7 @@ func NewRepositories(cl client.Client) *Repositories { return &Repositories{ HealthCheck: health_check.NewHealthCheckRepository(), Namespace: namespaces.NewNamespaceRepository(cl), + Secret: secrets.NewSecretRepository(cl), Workspace: workspaces.NewWorkspaceRepository(cl), WorkspaceKind: workspacekinds.NewWorkspaceKindRepository(cl), } diff --git a/workspaces/backend/internal/repositories/secrets/repo.go b/workspaces/backend/internal/repositories/secrets/repo.go new file mode 100644 index 000000000..2f5d88f0d --- /dev/null +++ b/workspaces/backend/internal/repositories/secrets/repo.go @@ -0,0 +1,208 @@ +/* +Copyright 2024. + +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 secrets + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/secrets" +) + +var ( + ErrSecretNotFound = fmt.Errorf("secret not found") + ErrSecretAlreadyExists = fmt.Errorf("secret already exists") +) + +type SecretRepository struct { + client client.Client +} + +func NewSecretRepository(cl client.Client) *SecretRepository { + return &SecretRepository{ + client: cl, + } +} + +// GetSecrets returns a list of all secrets in a namespace. +// TODO: Implement actual K8s control plane interaction +func (r *SecretRepository) GetSecrets(ctx context.Context, namespace string) ([]models.SecretListItem, error) { + // Get mock secrets as K8s Secret objects + mockSecrets := getMockSecrets(namespace) + + // Convert to API models + secretList := make([]models.SecretListItem, len(mockSecrets)) + for i, secret := range mockSecrets { + secretList[i] = models.NewSecretListItemFromSecret(secret) + } + + return secretList, nil +} + +// GetSecret returns a specific secret by name and namespace. +// TODO: Implement actual K8s control plane interaction +func (r *SecretRepository) GetSecret(ctx context.Context, namespace string, secretName string) (*models.SecretUpdate, error) { + // Get mock secret as K8s Secret object + mockSecret := getMockSecret(namespace, secretName) + if mockSecret == nil { + return nil, ErrSecretNotFound + } + + // Convert to API model + secretUpdate := models.NewSecretUpdateModelFromSecret(mockSecret) + return &secretUpdate, nil +} + +// CreateSecret creates a new secret in the specified namespace. +// TODO: Implement actual K8s control plane interaction +func (r *SecretRepository) CreateSecret(ctx context.Context, namespace string, secretCreate *models.SecretCreate) (*models.SecretCreate, error) { + // Convert to K8s Secret object (using empty user email for mock) + // TODO: Get actual user email from context when implementing real K8s interaction + userEmail := "mock@example.com" + k8sSecret := secretCreate.ToKubernetesSecret(namespace, userEmail) + + // For now, just simulate creating the secret by converting back to model + // TODO: Actually create the secret in K8s + createdSecret := models.NewSecretCreateModelFromSecret(k8sSecret) + return &createdSecret, nil +} + +// UpdateSecret updates an existing secret in the specified namespace. +// TODO: Implement actual K8s control plane interaction +func (r *SecretRepository) UpdateSecret(ctx context.Context, namespace string, secretName string, secretUpdate *models.SecretUpdate) (*models.SecretUpdate, error) { + // Get existing mock secret to simulate update + currentSecret := getMockSecret(namespace, secretName) + if currentSecret == nil { + return nil, ErrSecretNotFound + } + + // Convert to K8s Secret object (using empty user email for mock) + // TODO: Get actual user email from context when implementing real K8s interaction + userEmail := "mock@example.com" + k8sSecret := secretUpdate.ToKubernetesSecret(currentSecret, userEmail) + + // For now, just simulate updating the secret by converting back to model + // TODO: Actually update the secret in K8s + updatedSecret := models.NewSecretUpdateModelFromSecret(k8sSecret) + return &updatedSecret, nil +} + +// DeleteSecret deletes a secret from the specified namespace. +// TODO: Implement actual K8s control plane interaction +func (r *SecretRepository) DeleteSecret(ctx context.Context, namespace string, secretName string) error { + // Check if secret exists in mock data + mockSecret := getMockSecret(namespace, secretName) + if mockSecret == nil { + return ErrSecretNotFound + } + + // TODO: Actually delete the secret in K8s + return nil +} + +// getMockSecrets returns temporary mock data as K8s Secret objects for frontend development +// TODO: Remove this function when actual repository implementation is ready +func getMockSecrets(namespace string) []*corev1.Secret { + return []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "database-credentials", + Namespace: namespace, + Labels: map[string]string{ + "notebooks.kubeflow.org/can-update": "true", + "notebooks.kubeflow.org/can-mount": "true", + }, + Annotations: map[string]string{ + "notebooks.kubeflow.org/created-by": "admin@example.com", + "notebooks.kubeflow.org/created-at": time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC).Format(time.RFC3339), + "notebooks.kubeflow.org/updated-by": "admin@example.com", + "notebooks.kubeflow.org/updated-at": time.Date(2024, 2, 20, 14, 45, 0, 0, time.UTC).Format(time.RFC3339), + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "username": []byte("dummy"), + "password": []byte("dummy"), + "host": []byte("dummy"), + "port": []byte("dummy"), + }, + Immutable: ptr.To(false), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "api-key-secret", + Namespace: namespace, + Labels: map[string]string{ + "notebooks.kubeflow.org/can-update": "false", + "notebooks.kubeflow.org/can-mount": "true", + }, + Annotations: map[string]string{ + "notebooks.kubeflow.org/created-by": "devops@example.com", + "notebooks.kubeflow.org/created-at": time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC).Format(time.RFC3339), + "notebooks.kubeflow.org/updated-by": "devops@example.com", + "notebooks.kubeflow.org/updated-at": time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC).Format(time.RFC3339), + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "api-key": []byte("dummy"), + "api-secret": []byte("dummy"), + }, + Immutable: ptr.To(true), + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-certificate", + Namespace: namespace, + Labels: map[string]string{ + "notebooks.kubeflow.org/can-update": "false", + "notebooks.kubeflow.org/can-mount": "true", + }, + Annotations: map[string]string{ + "notebooks.kubeflow.org/created-by": "security@example.com", + "notebooks.kubeflow.org/created-at": time.Date(2024, 3, 5, 16, 20, 0, 0, time.UTC).Format(time.RFC3339), + "notebooks.kubeflow.org/updated-by": "security@example.com", + "notebooks.kubeflow.org/updated-at": time.Date(2024, 3, 12, 11, 30, 0, 0, time.UTC).Format(time.RFC3339), + }, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + "tls.crt": []byte("dummy"), + "tls.key": []byte("dummy"), + }, + Immutable: ptr.To(false), + }, + } +} + +// getMockSecret returns temporary mock data as a K8s Secret object for a specific secret by name +// TODO: Remove this function when actual repository implementation is ready +func getMockSecret(namespace string, secretName string) *corev1.Secret { + mockSecrets := getMockSecrets(namespace) + for _, secret := range mockSecrets { + if secret.Name == secretName { + return secret + } + } + return nil // Return nil for unknown secret names to trigger 404 +} diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index d0b290754..24844a96b 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -85,6 +85,370 @@ const docTemplate = `{ } } }, + "/secrets/{namespace}": { + "get": { + "description": "Provides a list of all secrets that the user has access to in the specified namespace", + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Returns a list of all secrets in a namespace", + "operationId": "listSecrets", + "parameters": [ + { + "type": "string", + "x-example": "my-namespace", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful secrets response", + "schema": { + "$ref": "#/definitions/api.SecretListEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "post": { + "description": "Creates a new secret in the specified namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Creates a new secret", + "operationId": "createSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "description": "Secret creation request", + "name": "secret", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.SecretCreateEnvelope" + } + } + ], + "responses": { + "201": { + "description": "Secret created successfully", + "schema": { + "$ref": "#/definitions/api.SecretCreateEnvelope" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Secret already exists", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, + "/secrets/{namespace}/{name}": { + "get": { + "description": "Provides details of a specific secret by name and namespace", + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Returns a specific secret", + "operationId": "getSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-secret", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful secret response", + "schema": { + "$ref": "#/definitions/api.SecretEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "put": { + "description": "Updates an existing secret in the specified namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Updates an existing secret", + "operationId": "updateSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Secret update request", + "name": "secret", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/secrets.SecretUpdate" + } + } + ], + "responses": { + "200": { + "description": "Secret updated successfully", + "schema": { + "$ref": "#/definitions/api.SecretEnvelope" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "delete": { + "description": "Deletes a secret from the specified namespace", + "consumes": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Deletes a secret", + "operationId": "deleteSecret", + "parameters": [ + { + "type": "string", + "x-example": "my-namespace", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-secret", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspacekinds": { "get": { "description": "Returns a list of all available workspace kinds. Workspace kinds define the different types of workspaces that can be created in the system.", @@ -760,6 +1124,42 @@ const docTemplate = `{ } } }, + "api.SecretCreateEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/secrets.SecretCreate" + } + } + }, + "api.SecretEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/secrets.SecretUpdate" + } + } + }, + "api.SecretListEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/secrets.SecretListItem" + } + } + } + }, "api.ValidationError": { "type": "object", "required": [ @@ -851,6 +1251,29 @@ const docTemplate = `{ } } }, + "common.Audit": { + "type": "object", + "required": [ + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "updatedBy": { + "type": "string" + } + } + }, "field.ErrorType": { "type": "string", "enum": [ @@ -926,6 +1349,118 @@ const docTemplate = `{ } } }, + "secrets.SecretCreate": { + "type": "object", + "required": [ + "contents", + "immutable", + "name", + "type" + ], + "properties": { + "contents": { + "$ref": "#/definitions/secrets.SecretData" + }, + "immutable": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretData": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/secrets.SecretValue" + } + }, + "secrets.SecretListItem": { + "type": "object", + "required": [ + "audit", + "canMount", + "canUpdate", + "immutable", + "name", + "type" + ], + "properties": { + "audit": { + "$ref": "#/definitions/common.Audit" + }, + "canMount": { + "type": "boolean" + }, + "canUpdate": { + "type": "boolean" + }, + "immutable": { + "type": "boolean" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/definitions/secrets.SecretMount" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretMount": { + "type": "object", + "required": [ + "group", + "kind", + "name" + ], + "properties": { + "group": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "secrets.SecretUpdate": { + "type": "object", + "required": [ + "contents", + "immutable", + "type" + ], + "properties": { + "contents": { + "$ref": "#/definitions/secrets.SecretData" + }, + "immutable": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretValue": { + "type": "object", + "properties": { + "base64": { + "type": "string" + } + } + }, "workspacekinds.ImageConfig": { "type": "object", "required": [ diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index 33669b8a0..1cd9415cd 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -83,6 +83,370 @@ } } }, + "/secrets/{namespace}": { + "get": { + "description": "Provides a list of all secrets that the user has access to in the specified namespace", + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Returns a list of all secrets in a namespace", + "operationId": "listSecrets", + "parameters": [ + { + "type": "string", + "x-example": "my-namespace", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful secrets response", + "schema": { + "$ref": "#/definitions/api.SecretListEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "post": { + "description": "Creates a new secret in the specified namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Creates a new secret", + "operationId": "createSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "description": "Secret creation request", + "name": "secret", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.SecretCreateEnvelope" + } + } + ], + "responses": { + "201": { + "description": "Secret created successfully", + "schema": { + "$ref": "#/definitions/api.SecretCreateEnvelope" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Secret already exists", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, + "/secrets/{namespace}/{name}": { + "get": { + "description": "Provides details of a specific secret by name and namespace", + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Returns a specific secret", + "operationId": "getSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-secret", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful secret response", + "schema": { + "$ref": "#/definitions/api.SecretEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "put": { + "description": "Updates an existing secret in the specified namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Updates an existing secret", + "operationId": "updateSecret", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Secret update request", + "name": "secret", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/secrets.SecretUpdate" + } + } + ], + "responses": { + "200": { + "description": "Secret updated successfully", + "schema": { + "$ref": "#/definitions/api.SecretEnvelope" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + }, + "delete": { + "description": "Deletes a secret from the specified namespace", + "consumes": [ + "application/json" + ], + "tags": [ + "secrets" + ], + "summary": "Deletes a secret", + "operationId": "deleteSecret", + "parameters": [ + { + "type": "string", + "x-example": "my-namespace", + "description": "Namespace name", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-secret", + "description": "Secret name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Secret not found", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspacekinds": { "get": { "description": "Returns a list of all available workspace kinds. Workspace kinds define the different types of workspaces that can be created in the system.", @@ -758,6 +1122,42 @@ } } }, + "api.SecretCreateEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/secrets.SecretCreate" + } + } + }, + "api.SecretEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/secrets.SecretUpdate" + } + } + }, + "api.SecretListEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/secrets.SecretListItem" + } + } + } + }, "api.ValidationError": { "type": "object", "required": [ @@ -849,6 +1249,29 @@ } } }, + "common.Audit": { + "type": "object", + "required": [ + "createdAt", + "createdBy", + "updatedAt", + "updatedBy" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "updatedBy": { + "type": "string" + } + } + }, "field.ErrorType": { "type": "string", "enum": [ @@ -924,6 +1347,118 @@ } } }, + "secrets.SecretCreate": { + "type": "object", + "required": [ + "contents", + "immutable", + "name", + "type" + ], + "properties": { + "contents": { + "$ref": "#/definitions/secrets.SecretData" + }, + "immutable": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretData": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/secrets.SecretValue" + } + }, + "secrets.SecretListItem": { + "type": "object", + "required": [ + "audit", + "canMount", + "canUpdate", + "immutable", + "name", + "type" + ], + "properties": { + "audit": { + "$ref": "#/definitions/common.Audit" + }, + "canMount": { + "type": "boolean" + }, + "canUpdate": { + "type": "boolean" + }, + "immutable": { + "type": "boolean" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/definitions/secrets.SecretMount" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretMount": { + "type": "object", + "required": [ + "group", + "kind", + "name" + ], + "properties": { + "group": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "secrets.SecretUpdate": { + "type": "object", + "required": [ + "contents", + "immutable", + "type" + ], + "properties": { + "contents": { + "$ref": "#/definitions/secrets.SecretData" + }, + "immutable": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "secrets.SecretValue": { + "type": "object", + "properties": { + "base64": { + "type": "string" + } + } + }, "workspacekinds.ImageConfig": { "type": "object", "required": [