Skip to content

Commit 18495ae

Browse files
DavidHurtaclaude
andcommitted
pkg/cvo/metrics: Authorize using CN verification
In OpenShift, core operators SHOULD support local authorization and SHOULD allow the well-known metrics scraping identity (system:serviceaccount:openshift-monitoring:prometheus-k8s) to access the /metrics endpoint. They MAY support delegated authorization check via SubjectAccessReviews. [1] The well-known metrics scraping identity's client certificate is issued for the system:serviceaccount:openshift-monitoring:prometheus-k8s Common Name (CN) and signed by the kubernetes.io/kube-apiserver-client signer. [2] Thus, the commit utilizes this fact to check the client's certificate for this specific CN value. This is also done by the hardcodedauthorizer package utilized by other OpenShift operators for the metrics endpoint [3]. We could utilize the existing bearer token authorization as a fallback. However, I would like to minimize the attack surface. Especially for security things that we are implementing and testing, rather than importing from well-established modules. The commit implements a user information extraction from a certificate to minimize the needed dependencies. [1]: https://github.com/openshift/enhancements/blob/master/CONVENTIONS.md#metrics [2]: https://rhobs-handbook.netlify.app/products/openshiftmonitoring/collecting_metrics.md/#exposing-metrics-for-prometheus [3]: https://pkg.go.dev/github.com/openshift/library-go/pkg/authorization/hardcodedauthorizer Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 81f3bb5 commit 18495ae

2 files changed

Lines changed: 50 additions & 160 deletions

File tree

pkg/cvo/metrics.go

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,16 @@ import (
77
"fmt"
88
"net"
99
"net/http"
10-
"strings"
1110
"time"
1211

1312
"github.com/prometheus/client_golang/prometheus"
1413
"github.com/prometheus/client_golang/prometheus/promhttp"
15-
authenticationv1 "k8s.io/api/authentication/v1"
1614
corev1 "k8s.io/api/core/v1"
1715
apierrors "k8s.io/apimachinery/pkg/api/errors"
18-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1916
"k8s.io/apimachinery/pkg/labels"
2017
"k8s.io/apimachinery/pkg/util/sets"
2118
"k8s.io/apiserver/pkg/server/dynamiccertificates"
2219
"k8s.io/client-go/kubernetes"
23-
authenticationclientsetv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
2420
"k8s.io/client-go/rest"
2521
"k8s.io/client-go/tools/cache"
2622
"k8s.io/klog/v2"
@@ -127,7 +123,7 @@ type asyncResult struct {
127123
error error
128124
}
129125

130-
func createHttpServer(ctx context.Context, client *authenticationclientsetv1.AuthenticationV1Client, disableAuth bool) *http.Server {
126+
func createHttpServer(disableAuth bool) *http.Server {
131127
if disableAuth {
132128
handler := http.NewServeMux()
133129
handler.Handle("/metrics", promhttp.Handler())
@@ -137,7 +133,7 @@ func createHttpServer(ctx context.Context, client *authenticationclientsetv1.Aut
137133
return server
138134
}
139135

140-
auth := authHandler{downstream: promhttp.Handler(), ctx: ctx, client: client.TokenReviews()}
136+
auth := authHandler{downstream: promhttp.Handler()}
141137
handler := http.NewServeMux()
142138
handler.Handle("/metrics", &auth)
143139
server := &http.Server{
@@ -146,62 +142,32 @@ func createHttpServer(ctx context.Context, client *authenticationclientsetv1.Aut
146142
return server
147143
}
148144

149-
type tokenReviewInterface interface {
150-
Create(ctx context.Context, tokenReview *authenticationv1.TokenReview, opts metav1.CreateOptions) (*authenticationv1.TokenReview, error)
151-
}
152-
153145
type authHandler struct {
154146
downstream http.Handler
155-
ctx context.Context
156-
client tokenReviewInterface
157-
}
158-
159-
func (a *authHandler) authorize(token string) (bool, error) {
160-
tr := &authenticationv1.TokenReview{
161-
Spec: authenticationv1.TokenReviewSpec{
162-
Token: token,
163-
},
164-
}
165-
result, err := a.client.Create(a.ctx, tr, metav1.CreateOptions{})
166-
if err != nil {
167-
return false, fmt.Errorf("failed to check token: %w", err)
168-
}
169-
isAuthenticated := result.Status.Authenticated
170-
isPrometheus := result.Status.User.Username == "system:serviceaccount:openshift-monitoring:prometheus-k8s"
171-
if !isAuthenticated {
172-
klog.V(4).Info("The token cannot be authenticated.")
173-
} else if !isPrometheus {
174-
klog.V(4).Infof("Access the metrics from the unexpected user %s is denied.", result.Status.User.Username)
175-
}
176-
return isAuthenticated && isPrometheus, nil
177147
}
178148

179149
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
180-
authHeader := r.Header.Get("Authorization")
181-
if authHeader == "" {
182-
http.Error(w, "failed to get the Authorization header", http.StatusUnauthorized)
183-
return
184-
}
185-
token := strings.TrimPrefix(authHeader, "Bearer ")
186-
if token == "" {
187-
http.Error(w, "empty Bearer token", http.StatusUnauthorized)
188-
return
189-
}
190-
if token == authHeader {
191-
http.Error(w, "failed to get the Bearer token", http.StatusUnauthorized)
150+
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
151+
klog.V(4).Info("Client certificate required but not provided")
152+
http.Error(w, "client certificate required", http.StatusUnauthorized)
192153
return
193154
}
194155

195-
authorized, err := a.authorize(token)
196-
if err != nil {
197-
klog.Warningf("Failed to authorize token: %v", err)
198-
http.Error(w, "failed to authorize due to an internal error", http.StatusInternalServerError)
199-
return
200-
}
201-
if !authorized {
202-
http.Error(w, "failed to authorize", http.StatusUnauthorized)
156+
// metricsAllowedClientCommonName is the Common Name (CN) of the client certificate
157+
// that is authorized to access the metrics endpoint. This corresponds to the
158+
// well-known Prometheus service account in OpenShift monitoring.
159+
// See: https://github.com/openshift/enhancements/blob/master/CONVENTIONS.md#metrics
160+
metricsAllowedClientCommonName := "system:serviceaccount:openshift-monitoring:prometheus-k8s"
161+
162+
// The first element is the leaf certificate that the connection is verified against
163+
commonName := r.TLS.PeerCertificates[0].Subject.CommonName
164+
if commonName != metricsAllowedClientCommonName {
165+
klog.V(4).Infof("Access denied for common name: %s", commonName)
166+
http.Error(w, fmt.Sprintf("unauthorized common name: %s", commonName), http.StatusForbidden)
203167
return
204168
}
169+
170+
klog.V(5).Infof("Access granted for common name: %s", commonName)
205171
a.downstream.ServeHTTP(w, r)
206172
}
207173

@@ -288,11 +254,6 @@ func RunMetrics(runContext context.Context, shutdownContext context.Context, lis
288254
return fmt.Errorf("failed to initialize client CA controller: %w", err)
289255
}
290256

291-
client, err := authenticationclientsetv1.NewForConfig(restConfig)
292-
if err != nil {
293-
return fmt.Errorf("failed to create config: %w", err)
294-
}
295-
296257
// Start the client CA controller to begin watching the ConfigMap
297258
resultChannelCount++
298259
go func() {
@@ -325,7 +286,7 @@ func RunMetrics(runContext context.Context, shutdownContext context.Context, lis
325286
resultChannel <- asyncResult{name: "serving certification controller"}
326287
}()
327288

328-
server := createHttpServer(metricsContext, client, disableMetricsAuth)
289+
server := createHttpServer(disableMetricsAuth)
329290
tlsConfig := crypto.SecureTLSConfig(&tls.Config{
330291
GetConfigForClient: func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) {
331292
config, err := servingCertController.GetConfigForClient(clientHello)

pkg/cvo/metrics_test.go

Lines changed: 31 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package cvo
22

33
import (
4-
"context"
4+
"crypto/tls"
5+
"crypto/x509"
6+
"crypto/x509/pkix"
57
"errors"
68
"fmt"
7-
"io"
89
"net/http"
910
"net/http/httptest"
1011
"sort"
@@ -16,7 +17,6 @@ import (
1617
"github.com/google/go-cmp/cmp"
1718
"github.com/prometheus/client_golang/prometheus"
1819
dto "github.com/prometheus/client_model/go"
19-
authenticationv1 "k8s.io/api/authentication/v1"
2020
corev1 "k8s.io/api/core/v1"
2121
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2222
"k8s.io/client-go/tools/record"
@@ -1019,27 +1019,6 @@ func metricParts(t *testing.T, metric prometheus.Metric, labels ...string) strin
10191019
return strings.Join(parts, " ")
10201020
}
10211021

1022-
type fakeClient struct {
1023-
}
1024-
1025-
func (c *fakeClient) Create(_ context.Context, tokenReview *authenticationv1.TokenReview, _ metav1.CreateOptions) (*authenticationv1.TokenReview, error) {
1026-
if tokenReview != nil {
1027-
ret := tokenReview.DeepCopy()
1028-
if tokenReview.Spec.Token == "good" {
1029-
ret.Status.Authenticated = true
1030-
ret.Status.User.Username = "system:serviceaccount:openshift-monitoring:prometheus-k8s"
1031-
}
1032-
if tokenReview.Spec.Token == "authenticated" {
1033-
ret.Status.Authenticated = true
1034-
}
1035-
if tokenReview.Spec.Token == "error" {
1036-
return nil, errors.New("fake error")
1037-
}
1038-
return ret, nil
1039-
}
1040-
return nil, errors.New("nil input")
1041-
}
1042-
10431022
type okHandler struct {
10441023
}
10451024

@@ -1051,112 +1030,62 @@ func Test_authHandler(t *testing.T) {
10511030
tests := []struct {
10521031
name string
10531032
handler *authHandler
1054-
method string
1055-
body io.Reader
1056-
headerKey string
1057-
headerValue string
1033+
clientCN string
1034+
provideCert bool
10581035
expectedStatusCode int
10591036
expectedBody string
10601037
}{
10611038
{
1062-
name: "good",
1039+
name: "allowed CN - prometheus-k8s",
10631040
handler: &authHandler{
1064-
ctx: context.TODO(),
10651041
downstream: &okHandler{},
1066-
client: &fakeClient{},
10671042
},
1068-
method: "GET",
1069-
headerKey: "Authorization",
1070-
headerValue: "Bearer good",
1043+
clientCN: "system:serviceaccount:openshift-monitoring:prometheus-k8s",
1044+
provideCert: true,
10711045
expectedStatusCode: http.StatusOK,
10721046
expectedBody: "ok",
10731047
},
10741048
{
1075-
name: "empty bearer token",
1076-
handler: &authHandler{
1077-
ctx: context.TODO(),
1078-
downstream: &okHandler{},
1079-
client: &fakeClient{},
1080-
},
1081-
method: "GET",
1082-
headerKey: "Authorization",
1083-
headerValue: "Bearer ",
1084-
expectedStatusCode: 401,
1085-
expectedBody: "empty Bearer token\n",
1086-
},
1087-
{
1088-
name: "authenticated",
1089-
handler: &authHandler{
1090-
ctx: context.TODO(),
1091-
downstream: &okHandler{},
1092-
client: &fakeClient{},
1093-
},
1094-
method: "GET",
1095-
headerKey: "Authorization",
1096-
headerValue: "Bearer authenticated",
1097-
expectedStatusCode: 401,
1098-
expectedBody: "failed to authorize\n",
1099-
},
1100-
{
1101-
name: "bad",
1049+
name: "unauthorized CN",
11021050
handler: &authHandler{
1103-
ctx: context.TODO(),
11041051
downstream: &okHandler{},
1105-
client: &fakeClient{},
11061052
},
1107-
method: "GET",
1108-
headerKey: "Authorization",
1109-
headerValue: "Bearer bad",
1110-
expectedStatusCode: 401,
1111-
expectedBody: "failed to authorize\n",
1053+
clientCN: "system:serviceaccount:default:unauthorized",
1054+
provideCert: true,
1055+
expectedStatusCode: http.StatusForbidden,
1056+
expectedBody: "unauthorized common name: system:serviceaccount:default:unauthorized\n",
11121057
},
11131058
{
1114-
name: "failed to get the Authorization header",
1059+
name: "no client certificate",
11151060
handler: &authHandler{
1116-
ctx: context.TODO(),
11171061
downstream: &okHandler{},
1118-
client: &fakeClient{},
11191062
},
1120-
method: "GET",
1121-
expectedStatusCode: 401,
1122-
expectedBody: "failed to get the Authorization header\n",
1123-
},
1124-
{
1125-
name: "failed to get the Bearer token",
1126-
handler: &authHandler{
1127-
ctx: context.TODO(),
1128-
downstream: &okHandler{},
1129-
client: &fakeClient{},
1130-
},
1131-
method: "GET",
1132-
headerKey: "Authorization",
1133-
headerValue: "xxx bad",
1134-
expectedStatusCode: 401,
1135-
expectedBody: "failed to get the Bearer token\n",
1136-
},
1137-
{
1138-
name: "error",
1139-
handler: &authHandler{
1140-
ctx: context.TODO(),
1141-
downstream: &okHandler{},
1142-
client: &fakeClient{},
1143-
},
1144-
method: "GET",
1145-
headerKey: "Authorization",
1146-
headerValue: "Bearer error",
1147-
expectedStatusCode: 500,
1148-
expectedBody: "failed to authorize due to an internal error\n",
1063+
provideCert: false,
1064+
expectedStatusCode: http.StatusUnauthorized,
1065+
expectedBody: "client certificate required\n",
11491066
},
11501067
}
11511068
for _, tt := range tests {
11521069
t.Run(tt.name, func(t *testing.T) {
11531070
rr := httptest.NewRecorder()
11541071

1155-
req, err := http.NewRequest(tt.method, "url-not-important", tt.body)
1072+
req, err := http.NewRequest("GET", "url-not-important", nil)
11561073
if err != nil {
11571074
t.Fatal(err)
11581075
}
1159-
req.Header.Set(tt.headerKey, tt.headerValue)
1076+
1077+
// Mock TLS connection state with client certificate
1078+
if tt.provideCert {
1079+
req.TLS = &tls.ConnectionState{
1080+
PeerCertificates: []*x509.Certificate{
1081+
{
1082+
Subject: pkix.Name{
1083+
CommonName: tt.clientCN,
1084+
},
1085+
},
1086+
},
1087+
}
1088+
}
11601089

11611090
tt.handler.ServeHTTP(rr, req)
11621091
if diff := cmp.Diff(tt.expectedStatusCode, rr.Code); diff != "" {

0 commit comments

Comments
 (0)