From 895e38488b6bdddb878771d35db0039888ce8db4 Mon Sep 17 00:00:00 2001 From: Andy Stoneberg Date: Fri, 24 Oct 2025 16:52:07 -0400 Subject: [PATCH 1/2] feat: stub secrets API endpoints This commit introduces a stub of the secrets management API to the Kubeflow Notebooks workspace backend. It is intended to bootstrap API development but will require follow up work to actually implement the business logic in line with the API proposal. **API Endpoints Added:** - GET /api/v1/secrets/{namespace} - List all secrets in namespace - POST /api/v1/secrets/{namespace} - Create new secret - GET /api/v1/secrets/{namespace}/{name} - Get specific secret details - PUT /api/v1/secrets/{namespace}/{name} - Update existing secret - DELETE /api/v1/secrets/{namespace}/{name} - Delete secret **Key Features:** - Complete data models for secret operations (create, update, list, detail) - Comprehensive validation for secret keys and base64-encoded values - Full Swagger/OpenAPI documentation generation - Integration with existing authentication and authorization middleware - Support for secret mounting information and audit trails - Proper error handling and HTTP status codes **Known Omissions:** - No repository/ support for the stubbed API implementations - API serves mocked data for all requests and does not actually interact k8s cluster - Unit tests are very crude and presently rely on mocked data - Missing business logic for majority of API handlers This implementation provides the foundation for frontend integration to manage Kubernetes secrets through the workspace interface. Signed-off-by: Andy Stoneberg --- workspaces/backend/api/app.go | 11 + workspaces/backend/api/secrets_handler.go | 504 +++++++++++++++++ .../backend/api/secrets_handler_test.go | 443 +++++++++++++++ .../backend/internal/helper/validation.go | 38 ++ .../backend/internal/models/common/funcs.go | 51 ++ .../backend/internal/models/common/types.go | 29 + .../backend/internal/models/secrets/funcs.go | 54 ++ .../internal/models/secrets/funcs_write.go | 131 +++++ .../backend/internal/models/secrets/types.go | 39 ++ .../internal/models/secrets/types_write.go | 131 +++++ workspaces/backend/openapi/docs.go | 535 ++++++++++++++++++ workspaces/backend/openapi/swagger.json | 535 ++++++++++++++++++ 12 files changed, 2501 insertions(+) create mode 100644 workspaces/backend/api/secrets_handler.go create mode 100644 workspaces/backend/api/secrets_handler_test.go create mode 100644 workspaces/backend/internal/models/common/funcs.go create mode 100644 workspaces/backend/internal/models/common/types.go create mode 100644 workspaces/backend/internal/models/secrets/funcs.go create mode 100644 workspaces/backend/internal/models/secrets/funcs_write.go create mode 100644 workspaces/backend/internal/models/secrets/types.go create mode 100644 workspaces/backend/internal/models/secrets/types_write.go 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..74476e77b --- /dev/null +++ b/workspaces/backend/api/secrets_handler.go @@ -0,0 +1,504 @@ +/* +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 ( + "fmt" + "net/http" + "time" + + "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" + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/secrets" +) + +type SecretEnvelope Envelope[*models.SecretUpdate] +type SecretListEnvelope Envelope[[]models.SecretListItem] +type SecretCreateEnvelope Envelope[*models.SecretCreate] + +// 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 := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbList, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // TODO: Replace with actual repository call when implemented + // For now, return dummy data as stub + secretList := getMockSecrets() + responseEnvelope := &SecretListEnvelope{Data: secretList} + a.dataResponse(w, r, responseEnvelope) +} + +// getMockSecrets returns temporary mock data for frontend development +// TODO: Remove this function when actual repository implementation is ready +func getMockSecrets() []models.SecretListItem { + return []models.SecretListItem{ + { + Name: "database-credentials", + Type: "Opaque", + Immutable: false, + CanUpdate: true, + CanMount: true, + Mounts: []models.SecretMount{ + {Group: "apps", Kind: "Deployment", Name: "web-app"}, + {Group: "apps", Kind: "Deployment", Name: "api-server"}, + }, + Audit: common.Audit{ + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + CreatedBy: "admin@example.com", + UpdatedAt: time.Date(2024, 2, 20, 14, 45, 0, 0, time.UTC), + UpdatedBy: "admin@example.com", + }, + }, + { + Name: "api-key-secret", + Type: "Opaque", + Immutable: true, + CanUpdate: false, + CanMount: true, + Mounts: []models.SecretMount{ + {Group: "apps", Kind: "Deployment", Name: "external-api-client"}, + }, + Audit: common.Audit{ + CreatedAt: time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC), + CreatedBy: "devops@example.com", + UpdatedAt: time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC), + UpdatedBy: "devops@example.com", + }, + }, + { + Name: "tls-certificate", + Type: "kubernetes.io/tls", + Immutable: false, + CanUpdate: false, + CanMount: true, + Mounts: []models.SecretMount{ + {Group: "networking.k8s.io", Kind: "Ingress", Name: "web-ingress"}, + }, + Audit: common.Audit{ + CreatedAt: time.Date(2024, 3, 5, 16, 20, 0, 0, time.UTC), + CreatedBy: "security@example.com", + UpdatedAt: time.Date(2024, 3, 12, 11, 30, 0, 0, time.UTC), + UpdatedBy: "security@example.com", + }, + }, + } +} + +// 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 := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbGet, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // TODO: Replace with actual repository call when implemented + // For now, return mock data as stub + secret := getMockSecret(secretName) + if secret == nil { + a.notFoundResponse(w, r) + return + } + responseEnvelope := &SecretEnvelope{Data: secret} + a.dataResponse(w, r, responseEnvelope) +} + +// getMockSecret returns temporary mock data for a specific secret by name +// TODO: Remove this function when actual repository implementation is ready +func getMockSecret(secretName string) *models.SecretUpdate { + switch secretName { + case "database-credentials": + return &models.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: false, + Contents: models.SecretData{ + "username": models.SecretValue{}, + "password": models.SecretValue{}, + "host": models.SecretValue{}, + "port": models.SecretValue{}, + }, + }, + } + case "api-key-secret": + return &models.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: true, + Contents: models.SecretData{ + "api-key": models.SecretValue{}, + "api-secret": models.SecretValue{}, + }, + }, + } + case "tls-certificate": + return &models.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: "kubernetes.io/tls", + Immutable: false, + Contents: models.SecretData{ + "tls.crt": models.SecretValue{}, + "tls.key": models.SecretValue{}, + }, + }, + } + default: + return nil // Return nil for unknown secret names to trigger 404 + } +} + +// createMockSecretFromRequest returns temporary mock data based on the create request +// TODO: Remove this function when actual repository implementation is ready +func createMockSecretFromRequest(secretCreate *models.SecretCreate) *models.SecretCreate { + // Create empty contents to never expose actual secret values + contents := make(models.SecretData) + for key := range secretCreate.Contents { + contents[key] = models.SecretValue{} // Empty value - never return actual data + } + + // Use the request data to create a mock response + // This simulates what would happen after creating the secret + return &models.SecretCreate{ + Name: secretCreate.Name, + SecretBase: models.SecretBase{ + Type: secretCreate.Type, + Immutable: secretCreate.Immutable, + Contents: contents, + }, + } +} + +// updateMockSecretFromRequest returns temporary mock data based on the update request +// TODO: Remove this function when actual repository implementation is ready +func updateMockSecretFromRequest(secretName string, secretUpdate *models.SecretUpdate) *models.SecretUpdate { + // Check if the secret exists in our mock data + switch secretName { + case "database-credentials", "api-key-secret", "tls-certificate": + + // Create empty contents to never expose actual secret values + contents := make(models.SecretData) + for key := range secretUpdate.Contents { + contents[key] = models.SecretValue{} // Empty value - never return actual data + } + + // Return the updated secret data (simulating successful update) + return &models.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: secretUpdate.Type, + Immutable: secretUpdate.Immutable, + Contents: contents, + }, + } + default: + // Return nil for unknown secret names to trigger 404 + return nil + } +} + +// 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 := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbCreate, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: 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 + } + + // TODO: Replace with actual repository call when implemented + // For now, return mock data as stub + secret := createMockSecretFromRequest(bodyEnvelope.Data) + responseEnvelope := &SecretCreateEnvelope{Data: secret} + location := fmt.Sprintf("/secrets/%s/%s", namespace, bodyEnvelope.Data.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 := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbUpdate, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: 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 + } + + // TODO: Replace with actual repository call when implemented + // For now, return mock data as stub + secret := updateMockSecretFromRequest(secretName, bodyEnvelope.Data) + if secret == nil { + a.notFoundResponse(w, r) + 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 := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbDelete, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + }, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // TODO: Replace with actual repository call when implemented + // For now, always return 204 No Content as stub + 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..a380c5e7b --- /dev/null +++ b/workspaces/backend/api/secrets_handler_test.go @@ -0,0 +1,443 @@ +/* +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" + + 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.SecretCreate{ + Name: "test-secret", + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: false, + Contents: models.SecretData{ + "username": {Base64: "dGVzdHVzZXI="}, // base64 for "testuser" + "password": {Base64: "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.SecretCreate{ + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: false, + Contents: models.SecretData{ + "username": {Base64: "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.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: false, + Contents: models.SecretData{ + "username": {Base64: "dXBkYXRlZHVzZXI="}, // base64 for "updateduser" + "password": {Base64: "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.SecretUpdate{ + SecretBase: models.SecretBase{ + Type: "Opaque", + Immutable: false, + Contents: models.SecretData{ + "username": {Base64: "dXBkYXRlZHVzZXI="}, // base64 for "updateduser" + "password": {Base64: "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 (stub implementation)", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodDelete, "/api/v1/secrets/"+namespaceName+"/test-secret", 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: "test-secret"}, + } + 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()) + }) + }) +}) 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..743a3e3a4 --- /dev/null +++ b/workspaces/backend/internal/models/secrets/funcs_write.go @@ -0,0 +1,131 @@ +/* +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: SecretType(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: SecretType(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 != "" { + // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) + 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 != "" { + // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) + 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..b50d0f107 --- /dev/null +++ b/workspaces/backend/internal/models/secrets/types_write.go @@ -0,0 +1,131 @@ +/* +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" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" +) + +// SecretType represents the type of a secret +type SecretType string + +// 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 SecretType `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 +} + +// 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 value.Base64 != "" { + errs = append(errs, helper.ValidateFieldIsBase64Encoded(keyPath, value.Base64)...) + } + } + + return errs +} + +// ValidateSecretType validates the secret type field. +// Currently only supports "Opaque" type, with empty type defaulting to "Opaque". +func (s *SecretType) Validate(prefix *field.Path) []*field.Error { + var errs []*field.Error + + if s == nil || *s == "" { + return errs // nil or empty is valid for optional fields + } + + // Currently only "Opaque" type is supported + if *s != "Opaque" { + errs = append(errs, field.Invalid(prefix, *s, "only 'Opaque' type is supported")) + } + + return errs +} + +// ValidateBase validates the common fields of a secret (type and contents). +func (sb *SecretBase) ValidateBase(prefix *field.Path) []*field.Error { + var errs []*field.Error + + typePath := prefix.Child("type") + errs = append(errs, sb.Type.Validate(typePath)...) + + // Set default type if empty + if sb.Type == "" { + sb.Type = "Opaque" + } + + contentsPath := prefix.Child("contents") + errs = append(errs, sb.Contents.Validate(contentsPath)...) + + return errs +} 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": [ From 9357f73b2a7e051b94894b188db20a6933b20ad7 Mon Sep 17 00:00:00 2001 From: Andy Stoneberg Date: Mon, 10 Nov 2025 10:06:21 -0500 Subject: [PATCH 2/2] chore: apply PR feedback from initial review - added helper function for authPolicies - secretBase is now not exported - added helper functions to construct SecretCreate and SecretUpdate - SecretValue now a pointer - mock data defined in repository layer Signed-off-by: Andy Stoneberg --- workspaces/backend/api/secrets_handler.go | 287 +++++------------- .../backend/api/secrets_handler_test.go | 95 +++--- .../internal/models/secrets/funcs_write.go | 18 +- .../internal/models/secrets/types_write.go | 76 +++-- .../internal/repositories/repositories.go | 3 + .../internal/repositories/secrets/repo.go | 208 +++++++++++++ 6 files changed, 381 insertions(+), 306 deletions(-) create mode 100644 workspaces/backend/internal/repositories/secrets/repo.go diff --git a/workspaces/backend/api/secrets_handler.go b/workspaces/backend/api/secrets_handler.go index 74476e77b..48750ce50 100644 --- a/workspaces/backend/api/secrets_handler.go +++ b/workspaces/backend/api/secrets_handler.go @@ -17,9 +17,9 @@ limitations under the License. package api import ( + "errors" "fmt" "net/http" - "time" "github.com/julienschmidt/httprouter" corev1 "k8s.io/api/core/v1" @@ -28,14 +28,28 @@ import ( "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common" 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 @@ -62,84 +76,22 @@ func (a *App) GetSecretsHandler(w http.ResponseWriter, r *http.Request, ps httpr } // =========================== AUTH =========================== - authPolicies := []*auth.ResourcePolicy{ - auth.NewResourcePolicy( - auth.ResourceVerbList, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - }, - ), - } + authPolicies := getSecretAuthPolicies(auth.ResourceVerbList, namespace) if success := a.requireAuth(w, r, authPolicies); !success { return } // ============================================================ - // TODO: Replace with actual repository call when implemented - // For now, return dummy data as stub - secretList := getMockSecrets() + 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) } -// getMockSecrets returns temporary mock data for frontend development -// TODO: Remove this function when actual repository implementation is ready -func getMockSecrets() []models.SecretListItem { - return []models.SecretListItem{ - { - Name: "database-credentials", - Type: "Opaque", - Immutable: false, - CanUpdate: true, - CanMount: true, - Mounts: []models.SecretMount{ - {Group: "apps", Kind: "Deployment", Name: "web-app"}, - {Group: "apps", Kind: "Deployment", Name: "api-server"}, - }, - Audit: common.Audit{ - CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), - CreatedBy: "admin@example.com", - UpdatedAt: time.Date(2024, 2, 20, 14, 45, 0, 0, time.UTC), - UpdatedBy: "admin@example.com", - }, - }, - { - Name: "api-key-secret", - Type: "Opaque", - Immutable: true, - CanUpdate: false, - CanMount: true, - Mounts: []models.SecretMount{ - {Group: "apps", Kind: "Deployment", Name: "external-api-client"}, - }, - Audit: common.Audit{ - CreatedAt: time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC), - CreatedBy: "devops@example.com", - UpdatedAt: time.Date(2024, 1, 10, 9, 15, 0, 0, time.UTC), - UpdatedBy: "devops@example.com", - }, - }, - { - Name: "tls-certificate", - Type: "kubernetes.io/tls", - Immutable: false, - CanUpdate: false, - CanMount: true, - Mounts: []models.SecretMount{ - {Group: "networking.k8s.io", Kind: "Ingress", Name: "web-ingress"}, - }, - Audit: common.Audit{ - CreatedAt: time.Date(2024, 3, 5, 16, 20, 0, 0, time.UTC), - CreatedBy: "security@example.com", - UpdatedAt: time.Date(2024, 3, 12, 11, 30, 0, 0, time.UTC), - UpdatedBy: "security@example.com", - }, - }, - } -} - // GetSecretHandler returns a specific secret by name and namespace. // // @Summary Returns a specific secret @@ -170,124 +122,27 @@ func (a *App) GetSecretHandler(w http.ResponseWriter, r *http.Request, ps httpro } // =========================== AUTH =========================== - authPolicies := []*auth.ResourcePolicy{ - auth.NewResourcePolicy( - auth.ResourceVerbGet, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - }, - ), - } + authPolicies := getSecretAuthPolicies(auth.ResourceVerbGet, namespace) if success := a.requireAuth(w, r, authPolicies); !success { return } // ============================================================ - // TODO: Replace with actual repository call when implemented - // For now, return mock data as stub - secret := getMockSecret(secretName) - if secret == nil { - a.notFoundResponse(w, r) + 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) } -// getMockSecret returns temporary mock data for a specific secret by name -// TODO: Remove this function when actual repository implementation is ready -func getMockSecret(secretName string) *models.SecretUpdate { - switch secretName { - case "database-credentials": - return &models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: false, - Contents: models.SecretData{ - "username": models.SecretValue{}, - "password": models.SecretValue{}, - "host": models.SecretValue{}, - "port": models.SecretValue{}, - }, - }, - } - case "api-key-secret": - return &models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: true, - Contents: models.SecretData{ - "api-key": models.SecretValue{}, - "api-secret": models.SecretValue{}, - }, - }, - } - case "tls-certificate": - return &models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: "kubernetes.io/tls", - Immutable: false, - Contents: models.SecretData{ - "tls.crt": models.SecretValue{}, - "tls.key": models.SecretValue{}, - }, - }, - } - default: - return nil // Return nil for unknown secret names to trigger 404 - } -} - -// createMockSecretFromRequest returns temporary mock data based on the create request -// TODO: Remove this function when actual repository implementation is ready -func createMockSecretFromRequest(secretCreate *models.SecretCreate) *models.SecretCreate { - // Create empty contents to never expose actual secret values - contents := make(models.SecretData) - for key := range secretCreate.Contents { - contents[key] = models.SecretValue{} // Empty value - never return actual data - } - - // Use the request data to create a mock response - // This simulates what would happen after creating the secret - return &models.SecretCreate{ - Name: secretCreate.Name, - SecretBase: models.SecretBase{ - Type: secretCreate.Type, - Immutable: secretCreate.Immutable, - Contents: contents, - }, - } -} - -// updateMockSecretFromRequest returns temporary mock data based on the update request -// TODO: Remove this function when actual repository implementation is ready -func updateMockSecretFromRequest(secretName string, secretUpdate *models.SecretUpdate) *models.SecretUpdate { - // Check if the secret exists in our mock data - switch secretName { - case "database-credentials", "api-key-secret", "tls-certificate": - - // Create empty contents to never expose actual secret values - contents := make(models.SecretData) - for key := range secretUpdate.Contents { - contents[key] = models.SecretValue{} // Empty value - never return actual data - } - - // Return the updated secret data (simulating successful update) - return &models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: secretUpdate.Type, - Immutable: secretUpdate.Immutable, - Contents: contents, - }, - } - default: - // Return nil for unknown secret names to trigger 404 - return nil - } -} - // CreateSecretHandler creates a new secret. // // @Summary Creates a new secret @@ -320,16 +175,7 @@ func (a *App) CreateSecretHandler(w http.ResponseWriter, r *http.Request, ps htt } // =========================== AUTH =========================== - authPolicies := []*auth.ResourcePolicy{ - auth.NewResourcePolicy( - auth.ResourceVerbCreate, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - }, - ), - } + authPolicies := getSecretAuthPolicies(auth.ResourceVerbCreate, namespace) if success := a.requireAuth(w, r, authPolicies); !success { return } @@ -360,11 +206,19 @@ func (a *App) CreateSecretHandler(w http.ResponseWriter, r *http.Request, ps htt return } - // TODO: Replace with actual repository call when implemented - // For now, return mock data as stub - secret := createMockSecretFromRequest(bodyEnvelope.Data) + 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, bodyEnvelope.Data.Name) + location := fmt.Sprintf("/secrets/%s/%s", namespace, secret.Name) a.createdResponse(w, r, responseEnvelope, location) } @@ -403,16 +257,7 @@ func (a *App) UpdateSecretHandler(w http.ResponseWriter, r *http.Request, ps htt } // =========================== AUTH =========================== - authPolicies := []*auth.ResourcePolicy{ - auth.NewResourcePolicy( - auth.ResourceVerbUpdate, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - }, - ), - } + authPolicies := getSecretAuthPolicies(auth.ResourceVerbUpdate, namespace) if success := a.requireAuth(w, r, authPolicies); !success { return } @@ -443,13 +288,17 @@ func (a *App) UpdateSecretHandler(w http.ResponseWriter, r *http.Request, ps htt return } - // TODO: Replace with actual repository call when implemented - // For now, return mock data as stub - secret := updateMockSecretFromRequest(secretName, bodyEnvelope.Data) - if secret == nil { - a.notFoundResponse(w, r) + 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) } @@ -483,22 +332,22 @@ func (a *App) DeleteSecretHandler(w http.ResponseWriter, r *http.Request, ps htt } // =========================== AUTH =========================== - authPolicies := []*auth.ResourcePolicy{ - auth.NewResourcePolicy( - auth.ResourceVerbDelete, - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - }, - }, - ), - } + authPolicies := getSecretAuthPolicies(auth.ResourceVerbDelete, namespace) if success := a.requireAuth(w, r, authPolicies); !success { return } // ============================================================ - // TODO: Replace with actual repository call when implemented - // For now, always return 204 No Content as stub + 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 index a380c5e7b..64623c193 100644 --- a/workspaces/backend/api/secrets_handler_test.go +++ b/workspaces/backend/api/secrets_handler_test.go @@ -30,6 +30,7 @@ import ( . "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" ) @@ -198,17 +199,15 @@ var _ = Describe("Secrets Handler", func() { It("should return 201 for create secret (mock implementation)", func() { By("creating the HTTP request body") - createReq := models.SecretCreate{ - Name: "test-secret", - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: false, - Contents: models.SecretData{ - "username": {Base64: "dGVzdHVzZXI="}, // base64 for "testuser" - "password": {Base64: "dGVzdHBhc3M="}, // base64 for "testpass" - }, + 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()) @@ -258,15 +257,14 @@ var _ = Describe("Secrets Handler", func() { It("should return 422 for missing name", func() { By("creating the HTTP request body without name") - createReq := models.SecretCreate{ - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: false, - Contents: models.SecretData{ - "username": {Base64: "dGVzdHVzZXI="}, // base64 for "testuser" - }, + 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()) @@ -319,16 +317,14 @@ var _ = Describe("Secrets Handler", func() { It("should return 200 for update secret (mock implementation)", func() { By("creating the HTTP request body") - updateReq := models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: false, - Contents: models.SecretData{ - "username": {Base64: "dXBkYXRlZHVzZXI="}, // base64 for "updateduser" - "password": {Base64: "dXBkYXRlZHBhc3M="}, // base64 for "updatedpass" - }, + 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()) @@ -357,16 +353,14 @@ var _ = Describe("Secrets Handler", func() { It("should return 404 for update non-existent secret", func() { By("creating the HTTP request body") - updateReq := models.SecretUpdate{ - SecretBase: models.SecretBase{ - Type: "Opaque", - Immutable: false, - Contents: models.SecretData{ - "username": {Base64: "dXBkYXRlZHVzZXI="}, // base64 for "updateduser" - "password": {Base64: "dXBkYXRlZHBhc3M="}, // base64 for "updatedpass" - }, + 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()) @@ -418,9 +412,10 @@ var _ = Describe("Secrets Handler", func() { Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) }) - It("should return 204 for delete secret (stub implementation)", func() { + It("should return 204 for delete secret (mock implementation)", func() { By("creating the HTTP request") - req, err := http.NewRequest(http.MethodDelete, "/api/v1/secrets/"+namespaceName+"/test-secret", http.NoBody) + // 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") @@ -429,7 +424,7 @@ var _ = Describe("Secrets Handler", func() { By("executing DeleteSecretHandler") ps := httprouter.Params{ {Key: NamespacePathParam, Value: namespaceName}, - {Key: ResourceNamePathParam, Value: "test-secret"}, + {Key: ResourceNamePathParam, Value: "database-credentials"}, } rr := httptest.NewRecorder() a.DeleteSecretHandler(rr, req, ps) @@ -439,5 +434,27 @@ var _ = Describe("Secrets Handler", func() { 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/models/secrets/funcs_write.go b/workspaces/backend/internal/models/secrets/funcs_write.go index 743a3e3a4..d64c3db31 100644 --- a/workspaces/backend/internal/models/secrets/funcs_write.go +++ b/workspaces/backend/internal/models/secrets/funcs_write.go @@ -40,8 +40,8 @@ func NewSecretCreateModelFromSecret(secret *corev1.Secret) SecretCreate { return SecretCreate{ Name: secret.Name, - SecretBase: SecretBase{ - Type: SecretType(secret.Type), + secretBase: secretBase{ + Type: string(secret.Type), Immutable: ptr.Deref(secret.Immutable, false), Contents: contents, }, @@ -53,8 +53,8 @@ func NewSecretUpdateModelFromSecret(secret *corev1.Secret) SecretUpdate { contents := secretDataFromKubernetesSecret(secret.Data) return SecretUpdate{ - SecretBase: SecretBase{ - Type: SecretType(secret.Type), + secretBase: secretBase{ + Type: string(secret.Type), Immutable: ptr.Deref(secret.Immutable, false), Contents: contents, }, @@ -66,9 +66,10 @@ func (s *SecretCreate) ToKubernetesSecret(namespace string, userEmail string) *c // Convert SecretValue back to []byte for Kubernetes data := make(map[string][]byte) for key, value := range s.Contents { - if value.Base64 != "" { + if value.Base64 != nil { // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) - data[key] = []byte(value.Base64) + // Empty string is a valid value, so we include it + data[key] = []byte(*value.Base64) } } @@ -101,9 +102,10 @@ func (s *SecretUpdate) ToKubernetesSecret(currentSecret *corev1.Secret, userEmai // Convert SecretValue back to []byte for Kubernetes data := make(map[string][]byte) for key, value := range s.Contents { - if value.Base64 != "" { + if value.Base64 != nil { // Store base64-encoded string as []byte (Kubernetes expects base64-encoded data) - data[key] = []byte(value.Base64) + // Empty string is a valid value, so we include it + data[key] = []byte(*value.Base64) } } diff --git a/workspaces/backend/internal/models/secrets/types_write.go b/workspaces/backend/internal/models/secrets/types_write.go index b50d0f107..c173c945d 100644 --- a/workspaces/backend/internal/models/secrets/types_write.go +++ b/workspaces/backend/internal/models/secrets/types_write.go @@ -18,24 +18,22 @@ package secrets import ( "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" ) -// SecretType represents the type of a secret -type SecretType string - // SecretValue represents a secret value with base64 encoding type SecretValue struct { - Base64 string `json:"base64,omitempty"` + 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 SecretType `json:"type"` +// 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"` } @@ -43,12 +41,35 @@ type SecretBase struct { // SecretCreate is used to create a new secret. type SecretCreate struct { Name string `json:"name"` - SecretBase + secretBase } // SecretUpdate represents the request body for updating a secret type SecretUpdate struct { - SecretBase + 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. @@ -61,7 +82,7 @@ func (s *SecretCreate) Validate(prefix *field.Path) []*field.Error { errs = append(errs, helper.ValidateFieldIsDNS1123Subdomain(namePath, s.Name)...) // validate common fields (type and contents) - errs = append(errs, s.SecretBase.ValidateBase(prefix)...) + errs = append(errs, s.secretBase.validateBase(prefix)...) return errs } @@ -70,7 +91,7 @@ func (s *SecretCreate) Validate(prefix *field.Path) []*field.Error { // 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) + return s.secretBase.validateBase(prefix) } // ValidateContents validates the contents map of a secret. @@ -87,43 +108,18 @@ func (s *SecretData) Validate(prefix *field.Path) []*field.Error { // TODO: determine proper way to validate secret values // Only validate base64 if it's present - if value.Base64 != "" { - errs = append(errs, helper.ValidateFieldIsBase64Encoded(keyPath, value.Base64)...) + if base64Value := ptr.Deref(value.Base64, ""); base64Value != "" { + errs = append(errs, helper.ValidateFieldIsBase64Encoded(keyPath, base64Value)...) } } return errs } -// ValidateSecretType validates the secret type field. -// Currently only supports "Opaque" type, with empty type defaulting to "Opaque". -func (s *SecretType) Validate(prefix *field.Path) []*field.Error { +// validateBase validates the common fields of a secret (contents). +func (sb *secretBase) validateBase(prefix *field.Path) []*field.Error { var errs []*field.Error - if s == nil || *s == "" { - return errs // nil or empty is valid for optional fields - } - - // Currently only "Opaque" type is supported - if *s != "Opaque" { - errs = append(errs, field.Invalid(prefix, *s, "only 'Opaque' type is supported")) - } - - return errs -} - -// ValidateBase validates the common fields of a secret (type and contents). -func (sb *SecretBase) ValidateBase(prefix *field.Path) []*field.Error { - var errs []*field.Error - - typePath := prefix.Child("type") - errs = append(errs, sb.Type.Validate(typePath)...) - - // Set default type if empty - if sb.Type == "" { - sb.Type = "Opaque" - } - contentsPath := prefix.Child("contents") errs = append(errs, sb.Contents.Validate(contentsPath)...) 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 +}