|
| 1 | +package clusterrole |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "net/http" |
| 6 | + "os" |
| 7 | + "slices" |
| 8 | + "strings" |
| 9 | + |
| 10 | + "github.com/openshift/managed-cluster-validating-webhooks/pkg/webhooks/utils" |
| 11 | + admissionv1 "k8s.io/api/admission/v1" |
| 12 | + admissionregv1 "k8s.io/api/admissionregistration/v1" |
| 13 | + corev1 "k8s.io/api/core/v1" |
| 14 | + rbacv1 "k8s.io/api/rbac/v1" |
| 15 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 16 | + "k8s.io/apimachinery/pkg/runtime" |
| 17 | + logf "sigs.k8s.io/controller-runtime/pkg/log" |
| 18 | + admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission" |
| 19 | +) |
| 20 | + |
| 21 | +const ( |
| 22 | + WebhookName string = "clusterroles-validation" |
| 23 | + backplanePrefix string = "backplane-" |
| 24 | + docString string = `Managed OpenShift Customers may not delete protected ClusterRoles including cluster-admin, view, edit, admin, specific system roles (system:admin, system:node, system:node-proxier, system:kube-scheduler, system:kube-controller-manager), and backplane-* roles` |
| 25 | +) |
| 26 | + |
| 27 | +var ( |
| 28 | + timeout int32 = 2 |
| 29 | + log = logf.Log.WithName(WebhookName) |
| 30 | + scope = admissionregv1.ClusterScope |
| 31 | + rules = []admissionregv1.RuleWithOperations{ |
| 32 | + { |
| 33 | + Operations: []admissionregv1.OperationType{"DELETE"}, |
| 34 | + Rule: admissionregv1.Rule{ |
| 35 | + APIGroups: []string{"rbac.authorization.k8s.io"}, |
| 36 | + APIVersions: []string{"v1"}, |
| 37 | + Resources: []string{"clusterroles"}, |
| 38 | + Scope: &scope, |
| 39 | + }, |
| 40 | + }, |
| 41 | + } |
| 42 | + |
| 43 | + // Protected ClusterRoles that should not be deleted |
| 44 | + protectedClusterRoles = []string{ |
| 45 | + "cluster-admin", |
| 46 | + "view", |
| 47 | + "edit", |
| 48 | + "admin", |
| 49 | + "system:admin", |
| 50 | + "system:node", |
| 51 | + "system:kube-scheduler", |
| 52 | + "system:kube-controller-manager", |
| 53 | + } |
| 54 | + |
| 55 | + // Users allowed to delete protected ClusterRoles |
| 56 | + allowedUsers = []string{ |
| 57 | + "backplane-cluster-admin", |
| 58 | + } |
| 59 | + |
| 60 | + // Groups allowed to delete protected ClusterRoles |
| 61 | + allowedGroups = []string{ |
| 62 | + "system:serviceaccounts:openshift-backplane-srep", |
| 63 | + } |
| 64 | +) |
| 65 | + |
| 66 | +type ClusterRoleWebHook struct { |
| 67 | + s runtime.Scheme |
| 68 | +} |
| 69 | + |
| 70 | +// NewWebhook creates the new webhook |
| 71 | +func NewWebhook() *ClusterRoleWebHook { |
| 72 | + scheme := runtime.NewScheme() |
| 73 | + err := admissionv1.AddToScheme(scheme) |
| 74 | + if err != nil { |
| 75 | + log.Error(err, "Fail adding admissionsv1 scheme to ClusterRoleWebHook") |
| 76 | + os.Exit(1) |
| 77 | + } |
| 78 | + err = corev1.AddToScheme(scheme) |
| 79 | + if err != nil { |
| 80 | + log.Error(err, "Fail adding corev1 scheme to ClusterRoleWebHook") |
| 81 | + os.Exit(1) |
| 82 | + } |
| 83 | + |
| 84 | + return &ClusterRoleWebHook{ |
| 85 | + s: *scheme, |
| 86 | + } |
| 87 | +} |
| 88 | + |
| 89 | +// Authorized implements Webhook interface |
| 90 | +func (s *ClusterRoleWebHook) Authorized(request admissionctl.Request) admissionctl.Response { |
| 91 | + return s.authorized(request) |
| 92 | +} |
| 93 | + |
| 94 | +func (s *ClusterRoleWebHook) authorized(request admissionctl.Request) admissionctl.Response { |
| 95 | + var ret admissionctl.Response |
| 96 | + |
| 97 | + if request.AdmissionRequest.UserInfo.Username == "system:unauthenticated" { |
| 98 | + log.Info("system:unauthenticated made a webhook request. Check RBAC rules", "request", request.AdmissionRequest) |
| 99 | + ret = admissionctl.Denied("Unauthenticated") |
| 100 | + ret.UID = request.AdmissionRequest.UID |
| 101 | + return ret |
| 102 | + } |
| 103 | + |
| 104 | + if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "system:") && request.AdmissionRequest.UserInfo.Username != "system:admin" { |
| 105 | + ret = admissionctl.Allowed("authenticated system: users are allowed") |
| 106 | + ret.UID = request.AdmissionRequest.UID |
| 107 | + return ret |
| 108 | + } |
| 109 | + |
| 110 | + if strings.HasPrefix(request.AdmissionRequest.UserInfo.Username, "kube:") { |
| 111 | + ret = admissionctl.Allowed("kube: users are allowed") |
| 112 | + ret.UID = request.AdmissionRequest.UID |
| 113 | + return ret |
| 114 | + } |
| 115 | + |
| 116 | + clusterRole, err := s.renderClusterRole(request) |
| 117 | + if err != nil { |
| 118 | + log.Error(err, "Couldn't render a ClusterRole from the incoming request") |
| 119 | + return admissionctl.Errored(http.StatusBadRequest, err) |
| 120 | + } |
| 121 | + |
| 122 | + log.Info(fmt.Sprintf("Found clusterrole: %v", clusterRole.Name)) |
| 123 | + |
| 124 | + if isProtectedClusterRole(clusterRole) && !isAllowedUserGroup(request) { |
| 125 | + switch request.Operation { |
| 126 | + case admissionv1.Delete: |
| 127 | + log.Info(fmt.Sprintf("Deleting operation detected on ClusterRole: %v", clusterRole.Name)) |
| 128 | + |
| 129 | + ret = admissionctl.Denied(fmt.Sprintf("Deleting ClusterRole %v is not allowed", clusterRole.Name)) |
| 130 | + ret.UID = request.AdmissionRequest.UID |
| 131 | + return ret |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + ret = admissionctl.Allowed("Request is allowed") |
| 136 | + ret.UID = request.AdmissionRequest.UID |
| 137 | + return ret |
| 138 | +} |
| 139 | + |
| 140 | +// renderClusterRole renders the ClusterRole object from the request |
| 141 | +func (s *ClusterRoleWebHook) renderClusterRole(request admissionctl.Request) (*rbacv1.ClusterRole, error) { |
| 142 | + decoder := admissionctl.NewDecoder(&s.s) |
| 143 | + clusterRole := &rbacv1.ClusterRole{} |
| 144 | + |
| 145 | + var err error |
| 146 | + if len(request.OldObject.Raw) > 0 { |
| 147 | + err = decoder.DecodeRaw(request.OldObject, clusterRole) |
| 148 | + } |
| 149 | + if err != nil { |
| 150 | + return nil, err |
| 151 | + } |
| 152 | + |
| 153 | + return clusterRole, nil |
| 154 | +} |
| 155 | + |
| 156 | +// isAllowedUserGroup checks if the user or group is allowed to perform the action |
| 157 | +func isAllowedUserGroup(request admissionctl.Request) bool { |
| 158 | + if slices.Contains(allowedUsers, request.UserInfo.Username) { |
| 159 | + return true |
| 160 | + } |
| 161 | + for _, group := range allowedGroups { |
| 162 | + if slices.Contains(request.UserInfo.Groups, group) { |
| 163 | + return true |
| 164 | + } |
| 165 | + } |
| 166 | + return false |
| 167 | +} |
| 168 | + |
| 169 | +// isProtectedClusterRole returns true if the ClusterRole is in the protected list or matches protected patterns |
| 170 | +func isProtectedClusterRole(clusterRole *rbacv1.ClusterRole) bool { |
| 171 | + // Check if it's in the explicit protected list (includes specific system roles) |
| 172 | + if slices.Contains(protectedClusterRoles, clusterRole.Name) { |
| 173 | + return true |
| 174 | + } |
| 175 | + // Check if it matches backplane pattern |
| 176 | + if strings.HasPrefix(clusterRole.Name, backplanePrefix) { |
| 177 | + return true |
| 178 | + } |
| 179 | + return false |
| 180 | +} |
| 181 | + |
| 182 | +// GetURI implements Webhook interface |
| 183 | +func (s *ClusterRoleWebHook) GetURI() string { |
| 184 | + return "/" + WebhookName |
| 185 | +} |
| 186 | + |
| 187 | +// Validate implements Webhook interface |
| 188 | +func (s *ClusterRoleWebHook) Validate(request admissionctl.Request) bool { |
| 189 | + valid := true |
| 190 | + valid = valid && (request.UserInfo.Username != "") |
| 191 | + valid = valid && (request.Kind.Kind == "ClusterRole") |
| 192 | + |
| 193 | + return valid |
| 194 | +} |
| 195 | + |
| 196 | +// Name implements Webhook interface |
| 197 | +func (s *ClusterRoleWebHook) Name() string { |
| 198 | + return WebhookName |
| 199 | +} |
| 200 | + |
| 201 | +// FailurePolicy implements Webhook interface |
| 202 | +func (s *ClusterRoleWebHook) FailurePolicy() admissionregv1.FailurePolicyType { |
| 203 | + return admissionregv1.Ignore |
| 204 | +} |
| 205 | + |
| 206 | +// MatchPolicy implements Webhook interface |
| 207 | +func (s *ClusterRoleWebHook) MatchPolicy() admissionregv1.MatchPolicyType { |
| 208 | + return admissionregv1.Equivalent |
| 209 | +} |
| 210 | + |
| 211 | +// Rules implements Webhook interface |
| 212 | +func (s *ClusterRoleWebHook) Rules() []admissionregv1.RuleWithOperations { |
| 213 | + return rules |
| 214 | +} |
| 215 | + |
| 216 | +// ObjectSelector implements Webhook interface |
| 217 | +func (s *ClusterRoleWebHook) ObjectSelector() *metav1.LabelSelector { |
| 218 | + return nil |
| 219 | +} |
| 220 | + |
| 221 | +// SideEffects implements Webhook interface |
| 222 | +func (s *ClusterRoleWebHook) SideEffects() admissionregv1.SideEffectClass { |
| 223 | + return admissionregv1.SideEffectClassNone |
| 224 | +} |
| 225 | + |
| 226 | +// TimeoutSeconds implements Webhook interface |
| 227 | +func (s *ClusterRoleWebHook) TimeoutSeconds() int32 { |
| 228 | + return timeout |
| 229 | +} |
| 230 | + |
| 231 | +// Doc implements Webhook interface |
| 232 | +func (s *ClusterRoleWebHook) Doc() string { |
| 233 | + return docString |
| 234 | +} |
| 235 | + |
| 236 | +// SyncSetLabelSelector returns the label selector to use in the SyncSet. |
| 237 | +// Return utils.DefaultLabelSelector() to stick with the default |
| 238 | +func (s *ClusterRoleWebHook) SyncSetLabelSelector() metav1.LabelSelector { |
| 239 | + return utils.DefaultLabelSelector() |
| 240 | +} |
| 241 | + |
| 242 | +func (s *ClusterRoleWebHook) ClassicEnabled() bool { return true } |
| 243 | + |
| 244 | +func (s *ClusterRoleWebHook) HypershiftEnabled() bool { return true } |
0 commit comments