Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
353 changes: 353 additions & 0 deletions workspaces/backend/api/secrets_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
/*
Copyright 2024.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package api

import (
"errors"
"fmt"
"net/http"

"github.com/julienschmidt/httprouter"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/secrets"
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/secrets"
)

type SecretEnvelope Envelope[*models.SecretUpdate]
type SecretListEnvelope Envelope[[]models.SecretListItem]
type SecretCreateEnvelope Envelope[*models.SecretCreate]

// getSecretAuthPolicies creates authorization policies for secret operations.
func getSecretAuthPolicies(verb auth.ResourceVerb, namespace string) []*auth.ResourcePolicy {
return []*auth.ResourcePolicy{
auth.NewResourcePolicy(
verb,
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
},
},
),
}
}

// GetSecretsHandler returns a list of all secrets in a namespace.
//
// @Summary Returns a list of all secrets in a namespace
// @Description Provides a list of all secrets that the user has access to in the specified namespace
// @Tags secrets
// @ID listSecrets
// @Produce application/json
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
// @Success 200 {object} SecretListEnvelope "Successful secrets response"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /secrets/{namespace} [get]
func (a *App) GetSecretsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)

var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)

if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := getSecretAuthPolicies(auth.ResourceVerbList, namespace)
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

secretList, err := a.repositories.Secret.GetSecrets(r.Context(), namespace)
if err != nil {
a.serverErrorResponse(w, r, err)
return
}

responseEnvelope := &SecretListEnvelope{Data: secretList}
a.dataResponse(w, r, responseEnvelope)
}

// GetSecretHandler returns a specific secret by name and namespace.
//
// @Summary Returns a specific secret
// @Description Provides details of a specific secret by name and namespace
// @Tags secrets
// @ID getSecret
// @Produce application/json
// @Param namespace path string true "Namespace name"
// @Param name path string true "Secret name" extensions(x-example=my-secret)
// @Success 200 {object} SecretEnvelope "Successful secret response"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 404 {object} ErrorEnvelope "Secret not found"
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /secrets/{namespace}/{name} [get]
func (a *App) GetSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
secretName := ps.ByName(ResourceNamePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := getSecretAuthPolicies(auth.ResourceVerbGet, namespace)
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

secret, err := a.repositories.Secret.GetSecret(r.Context(), namespace, secretName)
if err != nil {
// Check if it's a not found error
if errors.Is(err, repository.ErrSecretNotFound) {
a.notFoundResponse(w, r)
return
}
a.serverErrorResponse(w, r, err)
return
}

responseEnvelope := &SecretEnvelope{Data: secret}
a.dataResponse(w, r, responseEnvelope)
}

// CreateSecretHandler creates a new secret.
//
// @Summary Creates a new secret
// @Description Creates a new secret in the specified namespace
// @Tags secrets
// @ID createSecret
// @Accept json
// @Produce json
// @Param namespace path string true "Namespace name"
// @Param secret body SecretCreateEnvelope true "Secret creation request"
// @Success 201 {object} SecretCreateEnvelope "Secret created successfully"
// @Failure 400 {object} ErrorEnvelope "Bad request"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 409 {object} ErrorEnvelope "Secret already exists"
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /secrets/{namespace} [post]
func (a *App) CreateSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)

var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)

if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := getSecretAuthPolicies(auth.ResourceVerbCreate, namespace)
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

// Parse request body
bodyEnvelope := &SecretCreateEnvelope{}
err := a.DecodeJSON(r, bodyEnvelope)
if err != nil {
if a.IsMaxBytesError(err) {
a.requestEntityTooLargeResponse(w, r, err)
return
}
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
return
}

// Validate the request body
dataPath := field.NewPath("data")
if bodyEnvelope.Data == nil {
valErrs := field.ErrorList{field.Required(dataPath, "data is required")}
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}
valErrs = bodyEnvelope.Data.Validate(dataPath)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}

secret, err := a.repositories.Secret.CreateSecret(r.Context(), namespace, bodyEnvelope.Data)
if err != nil {
// Check if it's an already exists error
if errors.Is(err, repository.ErrSecretAlreadyExists) {
a.conflictResponse(w, r, err)
return
}
a.serverErrorResponse(w, r, err)
return
}

responseEnvelope := &SecretCreateEnvelope{Data: secret}
location := fmt.Sprintf("/secrets/%s/%s", namespace, secret.Name)
a.createdResponse(w, r, responseEnvelope, location)
}

// UpdateSecretHandler updates an existing secret.
//
// @Summary Updates an existing secret
// @Description Updates an existing secret in the specified namespace
// @Tags secrets
// @ID updateSecret
// @Accept json
// @Produce json
// @Param namespace path string true "Namespace name"
// @Param name path string true "Secret name"
// @Param secret body models.SecretUpdate true "Secret update request"
// @Success 200 {object} SecretEnvelope "Secret updated successfully"
// @Failure 400 {object} ErrorEnvelope "Bad request"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 404 {object} ErrorEnvelope "Secret not found"
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /secrets/{namespace}/{name} [put]
func (a *App) UpdateSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
secretName := ps.ByName(ResourceNamePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := getSecretAuthPolicies(auth.ResourceVerbUpdate, namespace)
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

// Parse request body
bodyEnvelope := &SecretEnvelope{}
err := a.DecodeJSON(r, bodyEnvelope)
if err != nil {
if a.IsMaxBytesError(err) {
a.requestEntityTooLargeResponse(w, r, err)
return
}
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
return
}

// Validate the request body
dataPath := field.NewPath("data")
if bodyEnvelope.Data == nil {
valErrs := field.ErrorList{field.Required(dataPath, "data is required")}
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}
valErrs = bodyEnvelope.Data.Validate(dataPath)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}

secret, err := a.repositories.Secret.UpdateSecret(r.Context(), namespace, secretName, bodyEnvelope.Data)
if err != nil {
// Check if it's a not found error
if errors.Is(err, repository.ErrSecretNotFound) {
a.notFoundResponse(w, r)
return
}
a.serverErrorResponse(w, r, err)
return
}

responseEnvelope := &SecretEnvelope{Data: secret}
a.dataResponse(w, r, responseEnvelope)
}

// DeleteSecretHandler deletes a secret.
//
// @Summary Deletes a secret
// @Description Deletes a secret from the specified namespace
// @Tags secrets
// @ID deleteSecret
// @Accept json
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
// @Param name path string true "Secret name" extensions(x-example=my-secret)
// @Success 204 "No Content"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 404 {object} ErrorEnvelope "Secret not found"
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /secrets/{namespace}/{name} [delete]
func (a *App) DeleteSecretHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
secretName := ps.ByName(ResourceNamePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), secretName)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := getSecretAuthPolicies(auth.ResourceVerbDelete, namespace)
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

err := a.repositories.Secret.DeleteSecret(r.Context(), namespace, secretName)
if err != nil {
// Check if it's a not found error
if errors.Is(err, repository.ErrSecretNotFound) {
a.notFoundResponse(w, r)
return
}
a.serverErrorResponse(w, r, err)
return
}

a.deletedResponse(w, r)
}
Loading