Skip to content
Merged
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
62 changes: 58 additions & 4 deletions backend/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"time"

"github.com/gorilla/securecookie"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/klog/v2"

"github.com/kube-bind/kube-bind/backend/kubernetes"
Expand Down Expand Up @@ -88,8 +89,7 @@ func NewAuthMiddleware(
func (am *AuthMiddleware) AuthenticateRequest(next http.Handler) http.Handler {
return am.authenticate(
am.verifyState(
next,
),
am.authorizeK8S(next)),
)
}

Expand All @@ -116,9 +116,13 @@ func (am *AuthMiddleware) authenticate(next http.Handler) http.Handler {
ClusterID: claims.ClusterID,
RedirectURL: claims.RedirectURL,
}
if claims.ExpiresAt != nil {
authCtx.SessionState.ExpiresAt = claims.ExpiresAt.Time
}

authCtx.ClientType = ClientTypeCLI
} else {
logger.V(2).Info("Invalid JWT token", "error", err)
logger.V(2).Error(err, "Invalid JWT token")
}
}
}
Expand All @@ -140,6 +144,8 @@ func (am *AuthMiddleware) authenticate(next http.Handler) http.Handler {
authCtx.SessionState = state
authCtx.ClientType = ClientTypeUI
}
} else {
logger.V(2).Error(err, "Failed to decode session cookie")
}
}

Expand Down Expand Up @@ -168,7 +174,13 @@ func (am *AuthMiddleware) verifyState(next http.Handler) http.Handler {
return
}

if state.IsExpired() || !am.isValidSession(state.SessionID) {
if state.IsExpired() {
logger.V(2).Info("Session has expired", "sessionID", state.SessionID)
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Session has expired")
return
}

if !am.isValidSession(state.SessionID) {
logger.V(2).Info("Session expired or invalid", "sessionID", state.SessionID)
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Session has expired or is invalid")
return
Expand All @@ -191,6 +203,30 @@ func (am *AuthMiddleware) isValidSession(sessionID string) bool {
return time.Now().Before(sessionInfo.ExpiresAt)
}

func (am *AuthMiddleware) authorizeK8S(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := klog.FromContext(r.Context())

authCtx := GetAuthContext(r.Context())
if !authCtx.IsValid { // should not happen if AuthenticateRequest is used before
logger.V(2).Info("Authentication context is not valid")
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Authentication context is not valid")
return
}

// Authorize against Kubernetes RBAC
err := am.kubernetesManager.AuthorizeRequest(r.Context(), authCtx.SessionState.Token.Subject, authCtx.SessionState.Token.Groups, authCtx.SessionState.ClusterID, r.Method, r.URL.Path)
if err != nil {
logger.V(2).Info("Kubernetes RBAC authorization failed", "error", err)
statusCode, code, details := mapErrorToCode(err)
writeErrorResponse(w, statusCode, code, "Cluster authorization failed. Missing required permissions in the cluster to access bindings.", details)
return
}

next.ServeHTTP(w, r)
})
}

func GetAuthContext(ctx context.Context) *AuthContext {
if authCtx, ok := ctx.Value(AuthContextKey).(*AuthContext); ok {
return authCtx
Expand All @@ -212,3 +248,21 @@ func RequireAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

// mapErrorToCode maps common errors to structured error codes
func mapErrorToCode(err error) (statusCode int, code string, details string) {
if apierrors.IsNotFound(err) {
return http.StatusNotFound, kubebindv1alpha2.ErrorCodeResourceNotFound, err.Error()
}
if apierrors.IsUnauthorized(err) {
return http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, err.Error()
}
if apierrors.IsForbidden(err) {
return http.StatusForbidden, kubebindv1alpha2.ErrorCodeAuthorizationFailed, err.Error()
}
if apierrors.IsBadRequest(err) {
return http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, err.Error()
}
// Default to internal server error
return http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, err.Error()
}
122 changes: 122 additions & 0 deletions backend/controllers/cluster/cluster_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
Copyright 2025 The Kube Bind Authors.

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 cluster

import (
"context"
"fmt"
"reflect"

rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/errors"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/log"
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"

kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
)

const (
controllerName = "kube-bind-backend-cluster"
)

// ClusterReconciler reconciles a Cluster object.
type ClusterReconciler struct {
manager mcmanager.Manager
opts controller.TypedOptions[mcreconcile.Request]
reconciler reconciler
}

// NewClusterReconciler returns a new ClusterReconciler to reconcile Clusters
// ands its resources.
func NewClusterReconciler(
_ context.Context,
mgr mcmanager.Manager,
opts controller.TypedOptions[mcreconcile.Request],
allowedGroups []string,
allowedUsers []string,
) (*ClusterReconciler, error) {
r := &ClusterReconciler{
manager: mgr,
opts: opts,
reconciler: reconciler{
allowedGroups: allowedGroups,
allowedUsers: allowedUsers,
},
}

return r, nil
}

//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=get;list;watch;create;update;patch;delete

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *ClusterReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Info("Reconciling Cluster", "request", req)

cl, err := r.manager.GetCluster(ctx, req.ClusterName)
if err != nil {
return ctrl.Result{}, fmt.Errorf("failed to get client for cluster %q: %w", req.ClusterName, err)
}

client := cl.GetClient()
cache := cl.GetCache()

cluster := &kubebindv1alpha2.Cluster{}
if err := client.Get(ctx, req.NamespacedName, cluster); err != nil {
if errors.IsNotFound(err) {
logger.Info("Cluster not found, skipping reconciliation")
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("failed to get Cluster: %w", err)
}

original := cluster.DeepCopy()
if err := r.reconciler.reconcile(ctx, client, cache, cluster); err != nil {
logger.Error(err, "Failed to reconcile Cluster")
return ctrl.Result{}, err
}

if !reflect.DeepEqual(original, cluster) {
err := client.Update(ctx, cluster)
if err != nil {
logger.Error(err, "Failed to update Cluster status")
return ctrl.Result{}, fmt.Errorf("failed to update Cluster status: %w", err)
}
logger.Info("Cluster status updated")
}

return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *ClusterReconciler) SetupWithManager(mgr mcmanager.Manager) error {
return mcbuilder.ControllerManagedBy(mgr).
For(&kubebindv1alpha2.Cluster{}).
Comment thread
ntnn marked this conversation as resolved.
Owns(&rbacv1.ClusterRole{}).
Owns(&rbacv1.ClusterRoleBinding{}).
Owns(&rbacv1.RoleBinding{}).
WithOptions(r.opts).
Named(controllerName).
Complete(r)
}
135 changes: 135 additions & 0 deletions backend/controllers/cluster/cluster_reconcile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
Copyright 2025 The Kube Bind Authors.

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 cluster

import (
"context"

rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
)

type reconciler struct {
allowedGroups []string
allowedUsers []string
}

func (r *reconciler) reconcile(ctx context.Context, client client.Client, _ cache.Cache, cluster *kubebindv1alpha2.Cluster) error {
var errs []error

if err := r.ensureOIDCRBAC(ctx, client, cluster); err != nil {
errs = append(errs, err)
}

return utilerrors.NewAggregate(errs)
}

func (r *reconciler) ensureOIDCRBAC(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
if err := r.ensureUserClusterRole(ctx, client, cluster); err != nil {
return err
}
return r.ensureUserClusterRoleBinding(ctx, client, cluster)
}

func (r *reconciler) ensureUserClusterRole(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
clusterRole := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-bind-oidc-user",
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"kube-bind.io"},
Resources: []string{"*"},
Verbs: []string{"bind"},
},
{
NonResourceURLs: []string{"/", "/api", "/api/*", "/apis", "/apis/*"},
Verbs: []string{"access"},
},
},
}

if err := controllerutil.SetControllerReference(cluster, clusterRole, client.Scheme()); err != nil {
return err
}

var existing rbacv1.ClusterRole
err := client.Get(ctx, types.NamespacedName{Name: "kube-bind-oidc-user"}, &existing)
if err != nil {
if errors.IsNotFound(err) {
return client.Create(ctx, clusterRole)
}
return err
}

existing.Rules = clusterRole.Rules
existing.OwnerReferences = clusterRole.OwnerReferences
return client.Update(ctx, &existing)
}

func (r *reconciler) ensureUserClusterRoleBinding(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-bind-oidc-user",
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "kube-bind-oidc-user",
},
}

for _, group := range r.allowedGroups {
clusterRoleBinding.Subjects = append(clusterRoleBinding.Subjects, rbacv1.Subject{
Kind: "Group",
Name: group,
})
}

for _, user := range r.allowedUsers {
clusterRoleBinding.Subjects = append(clusterRoleBinding.Subjects, rbacv1.Subject{
Kind: "User",
Name: user,
})
}

if err := controllerutil.SetControllerReference(cluster, clusterRoleBinding, client.Scheme()); err != nil {
return err
}

var existing rbacv1.ClusterRoleBinding
err := client.Get(ctx, types.NamespacedName{Name: "kube-bind-oidc-user"}, &existing)
if err != nil {
if errors.IsNotFound(err) {
return client.Create(ctx, clusterRoleBinding)
}
return err
}

existing.RoleRef = clusterRoleBinding.RoleRef
existing.Subjects = clusterRoleBinding.Subjects
existing.OwnerReferences = clusterRoleBinding.OwnerReferences
return client.Update(ctx, &existing)
}
Loading