Skip to content

Commit 65b721c

Browse files
authored
Add authz & cluster anchor (#428)
* add authz & cluster anchor Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt> On-behalf-of: @SAP mangirdas.judeikis@sap.com
1 parent 0455d3d commit 65b721c

36 files changed

Lines changed: 1466 additions & 13 deletions

File tree

backend/auth/middleware.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"time"
2525

2626
"github.com/gorilla/securecookie"
27+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2728
"k8s.io/klog/v2"
2829

2930
"github.com/kube-bind/kube-bind/backend/kubernetes"
@@ -88,8 +89,7 @@ func NewAuthMiddleware(
8889
func (am *AuthMiddleware) AuthenticateRequest(next http.Handler) http.Handler {
8990
return am.authenticate(
9091
am.verifyState(
91-
next,
92-
),
92+
am.authorizeK8S(next)),
9393
)
9494
}
9595

@@ -116,9 +116,13 @@ func (am *AuthMiddleware) authenticate(next http.Handler) http.Handler {
116116
ClusterID: claims.ClusterID,
117117
RedirectURL: claims.RedirectURL,
118118
}
119+
if claims.ExpiresAt != nil {
120+
authCtx.SessionState.ExpiresAt = claims.ExpiresAt.Time
121+
}
122+
119123
authCtx.ClientType = ClientTypeCLI
120124
} else {
121-
logger.V(2).Info("Invalid JWT token", "error", err)
125+
logger.V(2).Error(err, "Invalid JWT token")
122126
}
123127
}
124128
}
@@ -140,6 +144,8 @@ func (am *AuthMiddleware) authenticate(next http.Handler) http.Handler {
140144
authCtx.SessionState = state
141145
authCtx.ClientType = ClientTypeUI
142146
}
147+
} else {
148+
logger.V(2).Error(err, "Failed to decode session cookie")
143149
}
144150
}
145151

@@ -168,7 +174,13 @@ func (am *AuthMiddleware) verifyState(next http.Handler) http.Handler {
168174
return
169175
}
170176

171-
if state.IsExpired() || !am.isValidSession(state.SessionID) {
177+
if state.IsExpired() {
178+
logger.V(2).Info("Session has expired", "sessionID", state.SessionID)
179+
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Session has expired")
180+
return
181+
}
182+
183+
if !am.isValidSession(state.SessionID) {
172184
logger.V(2).Info("Session expired or invalid", "sessionID", state.SessionID)
173185
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Session has expired or is invalid")
174186
return
@@ -191,6 +203,30 @@ func (am *AuthMiddleware) isValidSession(sessionID string) bool {
191203
return time.Now().Before(sessionInfo.ExpiresAt)
192204
}
193205

206+
func (am *AuthMiddleware) authorizeK8S(next http.Handler) http.Handler {
207+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
208+
logger := klog.FromContext(r.Context())
209+
210+
authCtx := GetAuthContext(r.Context())
211+
if !authCtx.IsValid { // should not happen if AuthenticateRequest is used before
212+
logger.V(2).Info("Authentication context is not valid")
213+
writeErrorResponse(w, http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, "Authentication required", "Authentication context is not valid")
214+
return
215+
}
216+
217+
// Authorize against Kubernetes RBAC
218+
err := am.kubernetesManager.AuthorizeRequest(r.Context(), authCtx.SessionState.Token.Subject, authCtx.SessionState.Token.Groups, authCtx.SessionState.ClusterID, r.Method, r.URL.Path)
219+
if err != nil {
220+
logger.V(2).Info("Kubernetes RBAC authorization failed", "error", err)
221+
statusCode, code, details := mapErrorToCode(err)
222+
writeErrorResponse(w, statusCode, code, "Cluster authorization failed. Missing required permissions in the cluster to access bindings.", details)
223+
return
224+
}
225+
226+
next.ServeHTTP(w, r)
227+
})
228+
}
229+
194230
func GetAuthContext(ctx context.Context) *AuthContext {
195231
if authCtx, ok := ctx.Value(AuthContextKey).(*AuthContext); ok {
196232
return authCtx
@@ -212,3 +248,21 @@ func RequireAuth(next http.Handler) http.Handler {
212248
next.ServeHTTP(w, r)
213249
})
214250
}
251+
252+
// mapErrorToCode maps common errors to structured error codes
253+
func mapErrorToCode(err error) (statusCode int, code string, details string) {
254+
if apierrors.IsNotFound(err) {
255+
return http.StatusNotFound, kubebindv1alpha2.ErrorCodeResourceNotFound, err.Error()
256+
}
257+
if apierrors.IsUnauthorized(err) {
258+
return http.StatusUnauthorized, kubebindv1alpha2.ErrorCodeAuthenticationFailed, err.Error()
259+
}
260+
if apierrors.IsForbidden(err) {
261+
return http.StatusForbidden, kubebindv1alpha2.ErrorCodeAuthorizationFailed, err.Error()
262+
}
263+
if apierrors.IsBadRequest(err) {
264+
return http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, err.Error()
265+
}
266+
// Default to internal server error
267+
return http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, err.Error()
268+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
Copyright 2025 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cluster
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"reflect"
23+
24+
rbacv1 "k8s.io/api/rbac/v1"
25+
"k8s.io/apimachinery/pkg/api/errors"
26+
ctrl "sigs.k8s.io/controller-runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/controller"
28+
"sigs.k8s.io/controller-runtime/pkg/log"
29+
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
30+
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
31+
mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
32+
33+
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
34+
)
35+
36+
const (
37+
controllerName = "kube-bind-backend-cluster"
38+
)
39+
40+
// ClusterReconciler reconciles a Cluster object.
41+
type ClusterReconciler struct {
42+
manager mcmanager.Manager
43+
opts controller.TypedOptions[mcreconcile.Request]
44+
reconciler reconciler
45+
}
46+
47+
// NewClusterReconciler returns a new ClusterReconciler to reconcile Clusters
48+
// ands its resources.
49+
func NewClusterReconciler(
50+
_ context.Context,
51+
mgr mcmanager.Manager,
52+
opts controller.TypedOptions[mcreconcile.Request],
53+
allowedGroups []string,
54+
allowedUsers []string,
55+
) (*ClusterReconciler, error) {
56+
r := &ClusterReconciler{
57+
manager: mgr,
58+
opts: opts,
59+
reconciler: reconciler{
60+
allowedGroups: allowedGroups,
61+
allowedUsers: allowedUsers,
62+
},
63+
}
64+
65+
return r, nil
66+
}
67+
68+
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create;update;patch;delete
69+
//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=get;list;watch;create;update;patch;delete
70+
71+
// Reconcile is part of the main kubernetes reconciliation loop which aims to
72+
// move the current state of the cluster closer to the desired state.
73+
func (r *ClusterReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
74+
logger := log.FromContext(ctx)
75+
logger.Info("Reconciling Cluster", "request", req)
76+
77+
cl, err := r.manager.GetCluster(ctx, req.ClusterName)
78+
if err != nil {
79+
return ctrl.Result{}, fmt.Errorf("failed to get client for cluster %q: %w", req.ClusterName, err)
80+
}
81+
82+
client := cl.GetClient()
83+
cache := cl.GetCache()
84+
85+
cluster := &kubebindv1alpha2.Cluster{}
86+
if err := client.Get(ctx, req.NamespacedName, cluster); err != nil {
87+
if errors.IsNotFound(err) {
88+
logger.Info("Cluster not found, skipping reconciliation")
89+
return ctrl.Result{}, nil
90+
}
91+
return ctrl.Result{}, fmt.Errorf("failed to get Cluster: %w", err)
92+
}
93+
94+
original := cluster.DeepCopy()
95+
if err := r.reconciler.reconcile(ctx, client, cache, cluster); err != nil {
96+
logger.Error(err, "Failed to reconcile Cluster")
97+
return ctrl.Result{}, err
98+
}
99+
100+
if !reflect.DeepEqual(original, cluster) {
101+
err := client.Update(ctx, cluster)
102+
if err != nil {
103+
logger.Error(err, "Failed to update Cluster status")
104+
return ctrl.Result{}, fmt.Errorf("failed to update Cluster status: %w", err)
105+
}
106+
logger.Info("Cluster status updated")
107+
}
108+
109+
return ctrl.Result{}, nil
110+
}
111+
112+
// SetupWithManager sets up the controller with the Manager.
113+
func (r *ClusterReconciler) SetupWithManager(mgr mcmanager.Manager) error {
114+
return mcbuilder.ControllerManagedBy(mgr).
115+
For(&kubebindv1alpha2.Cluster{}).
116+
Owns(&rbacv1.ClusterRole{}).
117+
Owns(&rbacv1.ClusterRoleBinding{}).
118+
Owns(&rbacv1.RoleBinding{}).
119+
WithOptions(r.opts).
120+
Named(controllerName).
121+
Complete(r)
122+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
Copyright 2025 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cluster
18+
19+
import (
20+
"context"
21+
22+
rbacv1 "k8s.io/api/rbac/v1"
23+
"k8s.io/apimachinery/pkg/api/errors"
24+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/apimachinery/pkg/types"
26+
utilerrors "k8s.io/apimachinery/pkg/util/errors"
27+
"sigs.k8s.io/controller-runtime/pkg/cache"
28+
"sigs.k8s.io/controller-runtime/pkg/client"
29+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
30+
31+
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
32+
)
33+
34+
type reconciler struct {
35+
allowedGroups []string
36+
allowedUsers []string
37+
}
38+
39+
func (r *reconciler) reconcile(ctx context.Context, client client.Client, _ cache.Cache, cluster *kubebindv1alpha2.Cluster) error {
40+
var errs []error
41+
42+
if err := r.ensureOIDCRBAC(ctx, client, cluster); err != nil {
43+
errs = append(errs, err)
44+
}
45+
46+
return utilerrors.NewAggregate(errs)
47+
}
48+
49+
func (r *reconciler) ensureOIDCRBAC(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
50+
if err := r.ensureUserClusterRole(ctx, client, cluster); err != nil {
51+
return err
52+
}
53+
return r.ensureUserClusterRoleBinding(ctx, client, cluster)
54+
}
55+
56+
func (r *reconciler) ensureUserClusterRole(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
57+
clusterRole := &rbacv1.ClusterRole{
58+
ObjectMeta: metav1.ObjectMeta{
59+
Name: "kube-bind-oidc-user",
60+
},
61+
Rules: []rbacv1.PolicyRule{
62+
{
63+
APIGroups: []string{"kube-bind.io"},
64+
Resources: []string{"*"},
65+
Verbs: []string{"bind"},
66+
},
67+
{
68+
NonResourceURLs: []string{"/", "/api", "/api/*", "/apis", "/apis/*"},
69+
Verbs: []string{"access"},
70+
},
71+
},
72+
}
73+
74+
if err := controllerutil.SetControllerReference(cluster, clusterRole, client.Scheme()); err != nil {
75+
return err
76+
}
77+
78+
var existing rbacv1.ClusterRole
79+
err := client.Get(ctx, types.NamespacedName{Name: "kube-bind-oidc-user"}, &existing)
80+
if err != nil {
81+
if errors.IsNotFound(err) {
82+
return client.Create(ctx, clusterRole)
83+
}
84+
return err
85+
}
86+
87+
existing.Rules = clusterRole.Rules
88+
existing.OwnerReferences = clusterRole.OwnerReferences
89+
return client.Update(ctx, &existing)
90+
}
91+
92+
func (r *reconciler) ensureUserClusterRoleBinding(ctx context.Context, client client.Client, cluster *kubebindv1alpha2.Cluster) error {
93+
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
94+
ObjectMeta: metav1.ObjectMeta{
95+
Name: "kube-bind-oidc-user",
96+
},
97+
RoleRef: rbacv1.RoleRef{
98+
APIGroup: "rbac.authorization.k8s.io",
99+
Kind: "ClusterRole",
100+
Name: "kube-bind-oidc-user",
101+
},
102+
}
103+
104+
for _, group := range r.allowedGroups {
105+
clusterRoleBinding.Subjects = append(clusterRoleBinding.Subjects, rbacv1.Subject{
106+
Kind: "Group",
107+
Name: group,
108+
})
109+
}
110+
111+
for _, user := range r.allowedUsers {
112+
clusterRoleBinding.Subjects = append(clusterRoleBinding.Subjects, rbacv1.Subject{
113+
Kind: "User",
114+
Name: user,
115+
})
116+
}
117+
118+
if err := controllerutil.SetControllerReference(cluster, clusterRoleBinding, client.Scheme()); err != nil {
119+
return err
120+
}
121+
122+
var existing rbacv1.ClusterRoleBinding
123+
err := client.Get(ctx, types.NamespacedName{Name: "kube-bind-oidc-user"}, &existing)
124+
if err != nil {
125+
if errors.IsNotFound(err) {
126+
return client.Create(ctx, clusterRoleBinding)
127+
}
128+
return err
129+
}
130+
131+
existing.RoleRef = clusterRoleBinding.RoleRef
132+
existing.Subjects = clusterRoleBinding.Subjects
133+
existing.OwnerReferences = clusterRoleBinding.OwnerReferences
134+
return client.Update(ctx, &existing)
135+
}

0 commit comments

Comments
 (0)