diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go index 861e6d69904..0f054236ad5 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go @@ -22,6 +22,7 @@ import ( "fmt" "net/http" "strings" + "time" authenticationv1 "k8s.io/api/authentication/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -30,6 +31,7 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/filters" + "k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics" "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/server/httplog" @@ -42,10 +44,20 @@ import ( // expression of impersonation access. For example, a service account may be authorized to impersonate the // node that it is associated with but only when listing pods. See the linked KEP for further details. func WithConstrainedImpersonation(handler http.Handler, a authorizer.Authorizer, s runtime.NegotiatedSerializer) http.Handler { + metrics.RegisterMetrics() + + ma := &metricsAuthorizer{ + delegate: a, + recordAuthorizationCall: metrics.RecordImpersonationAuthorizationCall, + } + return &constrainedImpersonationHandler{ handler: handler, - tracker: newImpersonationModesTracker(a), + tracker: newImpersonationModesTracker(ma), s: s, + + recordAttempt: metrics.RecordImpersonationAttempt, + metricsAuthorizer: ma, } } @@ -53,6 +65,10 @@ type constrainedImpersonationHandler struct { handler http.Handler tracker *impersonationModesTracker s runtime.NegotiatedSerializer + + // to allow unit tests to override metrics recording + recordAttempt func(mode, decision string, duration time.Duration) + metricsAuthorizer *metricsAuthorizer } func (c *constrainedImpersonationHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { @@ -78,12 +94,16 @@ func (c *constrainedImpersonationHandler) ServeHTTP(w http.ResponseWriter, req * return } + start := time.Now() impersonatedUser, err := c.tracker.getImpersonatedUser(ctx, wantedUser, attributes) + duration := time.Since(start) if err != nil { + c.recordAttempt("", "denied", duration) klog.V(4).InfoS("Forbidden", "URI", req.RequestURI, "err", err) responsewriters.RespondWithError(w, req, err, c.s) return } + c.recordAttempt(modeFromConstraint(impersonatedUser.constraint), "allowed", duration) req = req.WithContext(request.WithUser(ctx, impersonatedUser.user)) httplog.LogOf(req, w).Addf("%v is impersonating %v", userString(requestor), userString(impersonatedUser.user)) @@ -249,3 +269,49 @@ func (t *impersonationModesTracker) getImpersonatedUser(ctx context.Context, wan // this should not happen, but make sure we fail closed when no impersonation mode succeeded return nil, errors.New("all impersonation modes failed") } + +type metricsAuthorizer struct { + delegate authorizer.Authorizer + recordAuthorizationCall func(mode, decision string, duration time.Duration) +} + +func (m *metricsAuthorizer) Authorize(ctx context.Context, attributes authorizer.Attributes) (authorizer.Decision, string, error) { + start := time.Now() + decision, reason, err := m.delegate.Authorize(ctx, attributes) + duration := time.Since(start) + + m.recordAuthorizationCall(modeFromVerb(attributes.GetVerb()), decisionToLabel(decision), duration) + + return decision, reason, err +} + +func modeFromConstraint(constraint string) string { + if len(constraint) == 0 { + return "legacy" + } + return modeFromVerb(constraint) +} + +func modeFromVerb(verb string) string { + switch { + case verb == "impersonate": + return "legacy" + case verb == "impersonate:associated-node" || strings.HasPrefix(verb, "impersonate-on:associated-node:"): + return "associated-node" + case verb == "impersonate:arbitrary-node" || strings.HasPrefix(verb, "impersonate-on:arbitrary-node:"): + return "arbitrary-node" + case verb == "impersonate:serviceaccount" || strings.HasPrefix(verb, "impersonate-on:serviceaccount:"): + return "serviceaccount" + case verb == "impersonate:user-info" || strings.HasPrefix(verb, "impersonate-on:user-info:"): + return "user-info" + default: + return "unknown" + } +} + +func decisionToLabel(decision authorizer.Decision) string { + if decision == authorizer.DecisionAllow { + return "allowed" + } + return "denied" +} diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go index 361e662f67f..df16b254574 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go @@ -26,6 +26,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -48,6 +49,10 @@ type constrainedImpersonationTest struct { constrainedImpersonationHandler *constrainedImpersonationHandler checkedAttrs []authorizer.Attributes echoCalled bool + + attemptMode string + attemptDecision string + authorizationMetrics map[string]int // "mode/decision" -> count } func (c *constrainedImpersonationTest) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { @@ -169,6 +174,17 @@ func (c *constrainedImpersonationTest) handler() http.Handler { addImpersonation := WithConstrainedImpersonation(c.echoUserInfoHandler(), c, serializer.NewCodecFactory(s)) c.constrainedImpersonationHandler = addImpersonation.(*constrainedImpersonationHandler) + c.constrainedImpersonationHandler.recordAttempt = func(mode, decision string, _ time.Duration) { + c.attemptMode = mode + c.attemptDecision = decision + } + c.constrainedImpersonationHandler.metricsAuthorizer.recordAuthorizationCall = func(mode, decision string, _ time.Duration) { + if c.authorizationMetrics == nil { + c.authorizationMetrics = map[string]int{} + } + c.authorizationMetrics[mode+"/"+decision]++ + } + addAuthentication := c.authenticationHandler(addImpersonation) addRequestInfo := c.requestInfoHandler(addAuthentication) return addRequestInfo @@ -249,6 +265,21 @@ func (c *constrainedImpersonationTest) assertEchoCalled(expectedCalled bool) { require.Equal(c.t, expectedCalled, called) } +func (c *constrainedImpersonationTest) assertMetrics(r testRequest) { + c.t.Helper() + + attemptMode := c.attemptMode + attemptDecision := c.attemptDecision + authorizationMetrics := c.authorizationMetrics + c.attemptMode = "" + c.attemptDecision = "" + c.authorizationMetrics = nil + + require.Equal(c.t, r.expectedAttemptMode, attemptMode, "unexpected attempt mode") + require.Equal(c.t, r.expectedAttemptDecision, attemptDecision, "unexpected attempt decision") + require.Equal(c.t, r.expectedAuthorizationMetrics, authorizationMetrics, "unexpected authorization metrics") +} + func (c *constrainedImpersonationTest) assertCache(r testRequest) { rr := require.New(c.t) @@ -380,6 +411,10 @@ type testRequest struct { expectedAttributes []authorizer.AttributesRecord expectedCache *expectedCache expectedCode int + + expectedAttemptMode string + expectedAttemptDecision string + expectedAuthorizationMetrics map[string]int } func associatedNodeTestCase() []testRequest { @@ -470,18 +505,26 @@ func associatedNodeTestCase() []testRequest { withImpersonateOnAttributes(getSecretRequest, "associated-node"), withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "nodes", Name: "*"}, "associated-node"), }, - expectedCache: cacheWithOnlyNode1Data, - expectedCode: http.StatusOK, + expectedCache: cacheWithOnlyNode1Data, + expectedCode: http.StatusOK, + expectedAttemptMode: "associated-node", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "associated-node/allowed": 2, // impersonate-on + impersonate:associated-node + }, }, { - request: getSecretRequest, - requestor: saDefaultOnNode2, - impersonatedUser: &user.DefaultInfo{Name: "system:node:node2"}, // node matches - expectedImpersonatedUser: node2FullUserInfo, - expectedAttributesUser: saDefaultOnAnyNode, - expectedAttributes: nil, // no authz checks for the second request - expectedCache: cacheWithOnlyNode1Data, - expectedCode: http.StatusOK, + request: getSecretRequest, + requestor: saDefaultOnNode2, + impersonatedUser: &user.DefaultInfo{Name: "system:node:node2"}, // node matches + expectedImpersonatedUser: node2FullUserInfo, + expectedAttributesUser: saDefaultOnAnyNode, + expectedAttributes: nil, // no authz checks for the second request + expectedCache: cacheWithOnlyNode1Data, + expectedCode: http.StatusOK, + expectedAttemptMode: "associated-node", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: nil, // full cache hit }, { request: getSecretRequest, @@ -494,8 +537,15 @@ func associatedNodeTestCase() []testRequest { withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "nodes", Name: "node1"}, "arbitrary-node"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "system:node:node1"}), }, - expectedCache: cacheWithOnlyNode1Data, - expectedCode: http.StatusForbidden, + expectedCache: cacheWithOnlyNode1Data, + expectedCode: http.StatusForbidden, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "arbitrary-node/allowed": 1, // impersonate-on:arbitrary-node:get + "arbitrary-node/denied": 1, // impersonate:arbitrary-node nodes + "legacy/denied": 1, // impersonate users + }, }, { request: getPodRequest, @@ -506,18 +556,26 @@ func associatedNodeTestCase() []testRequest { expectedAttributes: []authorizer.AttributesRecord{ withImpersonateOnAttributes(getPodRequest, "associated-node"), // one authz check because different request info }, - expectedCache: cacheWithMultipleRequests, - expectedCode: http.StatusOK, + expectedCache: cacheWithMultipleRequests, + expectedCode: http.StatusOK, + expectedAttemptMode: "associated-node", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "associated-node/allowed": 1, // impersonate-on only (inner cache hit) + }, }, { - request: getPodRequest, - requestor: saDefaultOnNode1, - impersonatedUser: &user.DefaultInfo{Name: "system:node:node1"}, // node matches - expectedImpersonatedUser: node1FullUserInfo, - expectedAttributesUser: nil, - expectedAttributes: nil, // no authz checks for pod request via node1 - expectedCache: cacheWithMultipleRequests, - expectedCode: http.StatusOK, + request: getPodRequest, + requestor: saDefaultOnNode1, + impersonatedUser: &user.DefaultInfo{Name: "system:node:node1"}, // node matches + expectedImpersonatedUser: node1FullUserInfo, + expectedAttributesUser: nil, + expectedAttributes: nil, // no authz checks for pod request via node1 + expectedCache: cacheWithMultipleRequests, + expectedCode: http.StatusOK, + expectedAttemptMode: "associated-node", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: nil, // full cache hit }, } } @@ -592,9 +650,16 @@ func TestConstrainedImpersonationFilter(t *testing.T) { withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "users", Name: "anyone"}, "user-info"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "anyone"}), }, - expectedCache: nil, - expectedCode: http.StatusForbidden, - expectedMessage: `users.authentication.k8s.io "anyone" is forbidden: User "tester" cannot impersonate:user-info resource "users" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedCache: nil, + expectedCode: http.StatusForbidden, + expectedMessage: `users.authentication.k8s.io "anyone" is forbidden: User "tester" cannot impersonate:user-info resource "users" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 1, // impersonate-on:user-info:get + "user-info/denied": 1, // impersonate:user-info users + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -625,7 +690,12 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "user-info", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 2, // impersonate-on + impersonate:user-info + }, }, { request: getAnotherPodRequest, @@ -651,7 +721,12 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "user-info", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 1, // impersonate-on (inner cache hit skips impersonate:user-info) + }, }, { request: createPodRequest, @@ -677,8 +752,14 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusForbidden, - expectedMessage: `pods "foo" is forbidden: User "user-impersonater" cannot impersonate-on:user-info:create resource "pods" in API group "" in the namespace "bar": deny by default`, + expectedCode: http.StatusForbidden, + expectedMessage: `pods "foo" is forbidden: User "user-impersonater" cannot impersonate-on:user-info:create resource "pods" in API group "" in the namespace "bar": deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "user-info/denied": 1, // impersonate-on:user-info:create + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -712,7 +793,12 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "serviceaccount", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "serviceaccount/allowed": 2, // impersonate-on + impersonate:serviceaccount + }, }, }, }, @@ -728,9 +814,16 @@ func TestConstrainedImpersonationFilter(t *testing.T) { withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "nodes", Name: "node1"}, "arbitrary-node"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "system:node:node1"}), }, - expectedCache: nil, - expectedCode: http.StatusForbidden, - expectedMessage: `nodes.authentication.k8s.io "node1" is forbidden: User "sa-impersonater" cannot impersonate:arbitrary-node resource "nodes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedCache: nil, + expectedCode: http.StatusForbidden, + expectedMessage: `nodes.authentication.k8s.io "node1" is forbidden: User "sa-impersonater" cannot impersonate:arbitrary-node resource "nodes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "arbitrary-node/allowed": 1, // impersonate-on:arbitrary-node:get + "arbitrary-node/denied": 1, // impersonate:arbitrary-node nodes + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -745,9 +838,15 @@ func TestConstrainedImpersonationFilter(t *testing.T) { withImpersonateOnAttributes(createPodRequest, "arbitrary-node"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "system:node:node1"}), }, - expectedCache: nil, - expectedCode: http.StatusForbidden, - expectedMessage: `pods "foo" is forbidden: User "node-impersonater" cannot impersonate-on:arbitrary-node:create resource "pods" in API group "" in the namespace "bar": deny by default`, + expectedCache: nil, + expectedCode: http.StatusForbidden, + expectedMessage: `pods "foo" is forbidden: User "node-impersonater" cannot impersonate-on:arbitrary-node:create resource "pods" in API group "" in the namespace "bar": deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "arbitrary-node/denied": 1, // impersonate-on:arbitrary-node:create + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -778,7 +877,12 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "arbitrary-node", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "arbitrary-node/allowed": 2, // impersonate-on + impersonate:arbitrary-node + }, }, }, }, @@ -803,9 +907,16 @@ func TestConstrainedImpersonationFilter(t *testing.T) { withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "userextras", Subresource: "pandas.io/scopes", Name: "scope-a"}, "user-info"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "system:admin"}), }, - expectedCache: nil, - expectedCode: http.StatusForbidden, - expectedMessage: `userextras.authentication.k8s.io "scope-a" is forbidden: User "user-impersonater" cannot impersonate:user-info resource "userextras/pandas.io/scopes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedCache: nil, + expectedCode: http.StatusForbidden, + expectedMessage: `userextras.authentication.k8s.io "scope-a" is forbidden: User "user-impersonater" cannot impersonate:user-info resource "userextras/pandas.io/scopes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 3, // impersonate-on:get + users + groups + "user-info/denied": 1, // userextras scope-a + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -880,7 +991,13 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "user-info", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 9, // impersonate-on:get + users + 7 individual extra values + "user-info/denied": 1, // wildcard userextras check + }, }, }, }, @@ -906,9 +1023,16 @@ func TestConstrainedImpersonationFilter(t *testing.T) { withConstrainedImpersonationAttributes(authorizer.AttributesRecord{Resource: "nodes", Name: "node1"}, "arbitrary-node"), withLegacyImpersonateAttributes(authorizer.AttributesRecord{Resource: "users", Name: "system:node:node1"}), }, - expectedCache: nil, - expectedCode: http.StatusForbidden, - expectedMessage: `nodes.authentication.k8s.io "node1" is forbidden: User "user-impersonater" cannot impersonate:arbitrary-node resource "nodes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedCache: nil, + expectedCode: http.StatusForbidden, + expectedMessage: `nodes.authentication.k8s.io "node1" is forbidden: User "user-impersonater" cannot impersonate:arbitrary-node resource "nodes" in API group "authentication.k8s.io" at the cluster scope: deny by default`, + expectedAttemptMode: "", + expectedAttemptDecision: "denied", + expectedAuthorizationMetrics: map[string]int{ + "arbitrary-node/allowed": 1, // impersonate-on:arbitrary-node:get + "arbitrary-node/denied": 1, // impersonate:arbitrary-node nodes + "legacy/denied": 1, // impersonate users + }, }, }, }, @@ -934,7 +1058,14 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, modes: nil, // legacy impersonation does not cache }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "legacy", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 1, // impersonate-on:user-info:get + "user-info/denied": 1, // impersonate:user-info users + "legacy/allowed": 1, // impersonate users + }, }, }, }, @@ -974,7 +1105,12 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, - expectedCode: http.StatusOK, + expectedCode: http.StatusOK, + expectedAttemptMode: "user-info", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: map[string]int{ + "user-info/allowed": 2, // impersonate-on + impersonate:user-info + }, }, { request: getDeploymentRequest, @@ -1006,6 +1142,9 @@ func TestConstrainedImpersonationFilter(t *testing.T) { }, }, }, + expectedAttemptMode: "user-info", + expectedAttemptDecision: "allowed", + expectedAuthorizationMetrics: nil, // full cache hit }, }, }, @@ -1082,6 +1221,7 @@ func TestConstrainedImpersonationFilter(t *testing.T) { test.assertAttributes(r) test.assertCache(r) + test.assertMetrics(r) } }) } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics.go new file mode 100644 index 00000000000..ac47de82a0f --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics.go @@ -0,0 +1,99 @@ +/* +Copyright The Kubernetes 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 metrics + +import ( + "sync" + "time" + + "k8s.io/component-base/metrics" + "k8s.io/component-base/metrics/legacyregistry" +) + +const ( + namespace = "apiserver" + subsystem = "impersonation" +) + +var ( + impersonationAttemptsTotal = metrics.NewCounterVec( + &metrics.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "attempts_total", + Help: "Total number of impersonation attempts split by mode and decision.", + StabilityLevel: metrics.ALPHA, + }, + []string{"mode", "decision"}, + ) + + impersonationAttemptsDurationSeconds = metrics.NewHistogramVec( + &metrics.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "attempts_duration_seconds", + Help: "Latency of impersonation attempts in seconds split by mode and decision.", + StabilityLevel: metrics.ALPHA, + Buckets: metrics.ExponentialBuckets(0.001, 2, 15), + }, + []string{"mode", "decision"}, + ) + + impersonationAuthorizationAttemptsTotal = metrics.NewCounterVec( + &metrics.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "authorization_attempts_total", + Help: "Total number of authorization checks made by the impersonation handler split by mode and decision.", + StabilityLevel: metrics.ALPHA, + }, + []string{"mode", "decision"}, + ) + + impersonationAuthorizationAttemptsDurationSeconds = metrics.NewHistogramVec( + &metrics.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "authorization_attempts_duration_seconds", + Help: "Latency of authorization checks made by the impersonation handler in seconds split by mode and decision.", + StabilityLevel: metrics.ALPHA, + Buckets: metrics.ExponentialBuckets(0.001, 2, 15), + }, + []string{"mode", "decision"}, + ) +) + +var registerMetrics sync.Once + +func RegisterMetrics() { + registerMetrics.Do(func() { + legacyregistry.MustRegister(impersonationAttemptsTotal) + legacyregistry.MustRegister(impersonationAttemptsDurationSeconds) + legacyregistry.MustRegister(impersonationAuthorizationAttemptsTotal) + legacyregistry.MustRegister(impersonationAuthorizationAttemptsDurationSeconds) + }) +} + +func RecordImpersonationAttempt(mode, decision string, duration time.Duration) { + impersonationAttemptsTotal.WithLabelValues(mode, decision).Inc() + impersonationAttemptsDurationSeconds.WithLabelValues(mode, decision).Observe(duration.Seconds()) +} + +func RecordImpersonationAuthorizationCall(mode, decision string, duration time.Duration) { + impersonationAuthorizationAttemptsTotal.WithLabelValues(mode, decision).Inc() + impersonationAuthorizationAttemptsDurationSeconds.WithLabelValues(mode, decision).Observe(duration.Seconds()) +} diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics_test.go new file mode 100644 index 00000000000..6eb4d0c960e --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/metrics/metrics_test.go @@ -0,0 +1,401 @@ +/* +Copyright The Kubernetes 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 metrics + +import ( + "strings" + "testing" + "time" + + "k8s.io/component-base/metrics/legacyregistry" + "k8s.io/component-base/metrics/testutil" +) + +func TestRecordImpersonationAttempt(t *testing.T) { + RegisterMetrics() + + attemptMetrics := []string{ + namespace + "_" + subsystem + "_attempts_total", + namespace + "_" + subsystem + "_attempts_duration_seconds", + } + + testCases := []struct { + name string + mode string + decision string + expectedValue string + }{ + { + name: "allowed with user-info mode", + mode: "user-info", + decision: "allowed", + expectedValue: ` + # HELP apiserver_impersonation_attempts_total [ALPHA] Total number of impersonation attempts split by mode and decision. + # TYPE apiserver_impersonation_attempts_total counter + apiserver_impersonation_attempts_total{decision="allowed",mode="user-info"} 1 + # HELP apiserver_impersonation_attempts_duration_seconds [ALPHA] Latency of impersonation attempts in seconds split by mode and decision. + # TYPE apiserver_impersonation_attempts_duration_seconds histogram + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.001"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.002"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.004"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.008"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.016"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.032"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.064"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.128"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.256"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.512"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="1.024"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="2.048"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="4.096"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="8.192"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="16.384"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="+Inf"} 1 + apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} 0.1 + apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 1 + `, + }, + { + name: "denied attempt", + mode: "", + decision: "denied", + expectedValue: ` + # HELP apiserver_impersonation_attempts_total [ALPHA] Total number of impersonation attempts split by mode and decision. + # TYPE apiserver_impersonation_attempts_total counter + apiserver_impersonation_attempts_total{decision="denied",mode=""} 1 + # HELP apiserver_impersonation_attempts_duration_seconds [ALPHA] Latency of impersonation attempts in seconds split by mode and decision. + # TYPE apiserver_impersonation_attempts_duration_seconds histogram + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.001"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.002"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.004"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.008"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.016"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.032"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.064"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.128"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.256"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.512"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="1.024"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="2.048"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="4.096"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="8.192"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="16.384"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="+Inf"} 1 + apiserver_impersonation_attempts_duration_seconds_sum{decision="denied",mode=""} 0.1 + apiserver_impersonation_attempts_duration_seconds_count{decision="denied",mode=""} 1 + `, + }, + { + name: "allowed with legacy mode", + mode: "legacy", + decision: "allowed", + expectedValue: ` + # HELP apiserver_impersonation_attempts_total [ALPHA] Total number of impersonation attempts split by mode and decision. + # TYPE apiserver_impersonation_attempts_total counter + apiserver_impersonation_attempts_total{decision="allowed",mode="legacy"} 1 + # HELP apiserver_impersonation_attempts_duration_seconds [ALPHA] Latency of impersonation attempts in seconds split by mode and decision. + # TYPE apiserver_impersonation_attempts_duration_seconds histogram + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.001"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.002"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.004"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.008"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.016"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.032"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.064"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.128"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.256"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.512"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="1.024"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="2.048"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="4.096"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="8.192"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="16.384"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="+Inf"} 1 + apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="legacy"} 0.1 + apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="legacy"} 1 + `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resetMetricsForTest() + + RecordImpersonationAttempt(tc.mode, tc.decision, 100*time.Millisecond) + + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(tc.expectedValue), attemptMetrics...); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRecordImpersonationAuthorizationCall(t *testing.T) { + RegisterMetrics() + + authorizationMetrics := []string{ + namespace + "_" + subsystem + "_authorization_attempts_total", + namespace + "_" + subsystem + "_authorization_attempts_duration_seconds", + } + + testCases := []struct { + name string + mode string + decision string + expectedValue string + }{ + { + name: "user-info allowed", + mode: "user-info", + decision: "allowed", + expectedValue: ` + # HELP apiserver_impersonation_authorization_attempts_total [ALPHA] Total number of authorization checks made by the impersonation handler split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_total counter + apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="user-info"} 1 + # HELP apiserver_impersonation_authorization_attempts_duration_seconds [ALPHA] Latency of authorization checks made by the impersonation handler in seconds split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_duration_seconds histogram + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.064"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.128"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.256"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.512"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="1.024"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="2.048"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="4.096"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="8.192"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="16.384"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="+Inf"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} 0.1 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 1 + `, + }, + { + name: "arbitrary-node denied", + mode: "arbitrary-node", + decision: "denied", + expectedValue: ` + # HELP apiserver_impersonation_authorization_attempts_total [ALPHA] Total number of authorization checks made by the impersonation handler split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_total counter + apiserver_impersonation_authorization_attempts_total{decision="denied",mode="arbitrary-node"} 1 + # HELP apiserver_impersonation_authorization_attempts_duration_seconds [ALPHA] Latency of authorization checks made by the impersonation handler in seconds split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_duration_seconds histogram + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.064"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.128"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.256"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="0.512"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="1.024"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="2.048"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="4.096"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="8.192"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="16.384"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="arbitrary-node",le="+Inf"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="arbitrary-node"} 0.1 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="arbitrary-node"} 1 + `, + }, + { + name: "legacy allowed", + mode: "legacy", + decision: "allowed", + expectedValue: ` + # HELP apiserver_impersonation_authorization_attempts_total [ALPHA] Total number of authorization checks made by the impersonation handler split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_total counter + apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="legacy"} 1 + # HELP apiserver_impersonation_authorization_attempts_duration_seconds [ALPHA] Latency of authorization checks made by the impersonation handler in seconds split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_duration_seconds histogram + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.064"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.128"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.256"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="0.512"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="1.024"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="2.048"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="4.096"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="8.192"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="16.384"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="legacy",le="+Inf"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="legacy"} 0.1 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="legacy"} 1 + `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resetMetricsForTest() + + RecordImpersonationAuthorizationCall(tc.mode, tc.decision, 100*time.Millisecond) + + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(tc.expectedValue), authorizationMetrics...); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestRecordImpersonationMetricsMultiple(t *testing.T) { + RegisterMetrics() + resetMetricsForTest() + + RecordImpersonationAttempt("user-info", "allowed", 100*time.Millisecond) + RecordImpersonationAttempt("", "denied", 50*time.Millisecond) + RecordImpersonationAttempt("", "denied", 50*time.Millisecond) + RecordImpersonationAuthorizationCall("user-info", "allowed", 100*time.Millisecond) + RecordImpersonationAuthorizationCall("user-info", "allowed", 100*time.Millisecond) + RecordImpersonationAuthorizationCall("user-info", "denied", 50*time.Millisecond) + RecordImpersonationAuthorizationCall("legacy", "denied", 50*time.Millisecond) + + expectedValue := ` + # HELP apiserver_impersonation_attempts_duration_seconds [ALPHA] Latency of impersonation attempts in seconds split by mode and decision. + # TYPE apiserver_impersonation_attempts_duration_seconds histogram + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.001"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.002"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.004"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.008"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.016"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.032"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.064"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.128"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.256"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.512"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="1.024"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="2.048"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="4.096"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="8.192"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="16.384"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="+Inf"} 1 + apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} 0.1 + apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 1 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.001"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.002"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.004"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.008"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.016"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.032"} 0 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.064"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.128"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.256"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="0.512"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="1.024"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="2.048"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="4.096"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="8.192"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="16.384"} 2 + apiserver_impersonation_attempts_duration_seconds_bucket{decision="denied",mode="",le="+Inf"} 2 + apiserver_impersonation_attempts_duration_seconds_sum{decision="denied",mode=""} 0.1 + apiserver_impersonation_attempts_duration_seconds_count{decision="denied",mode=""} 2 + # HELP apiserver_impersonation_attempts_total [ALPHA] Total number of impersonation attempts split by mode and decision. + # TYPE apiserver_impersonation_attempts_total counter + apiserver_impersonation_attempts_total{decision="allowed",mode="user-info"} 1 + apiserver_impersonation_attempts_total{decision="denied",mode=""} 2 + # HELP apiserver_impersonation_authorization_attempts_duration_seconds [ALPHA] Latency of authorization checks made by the impersonation handler in seconds split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_duration_seconds histogram + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.064"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.128"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.256"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="0.512"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="1.024"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="2.048"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="4.096"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="8.192"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="16.384"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="allowed",mode="user-info",le="+Inf"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} 0.2 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 2 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.064"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.128"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.256"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="0.512"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="1.024"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="2.048"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="4.096"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="8.192"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="16.384"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="legacy",le="+Inf"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="legacy"} 0.05 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="legacy"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.001"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.002"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.004"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.008"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.016"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.032"} 0 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.064"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.128"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.256"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="0.512"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="1.024"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="2.048"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="4.096"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="8.192"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="16.384"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_bucket{decision="denied",mode="user-info",le="+Inf"} 1 + apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="user-info"} 0.05 + apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="user-info"} 1 + # HELP apiserver_impersonation_authorization_attempts_total [ALPHA] Total number of authorization checks made by the impersonation handler split by mode and decision. + # TYPE apiserver_impersonation_authorization_attempts_total counter + apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="user-info"} 2 + apiserver_impersonation_authorization_attempts_total{decision="denied",mode="legacy"} 1 + apiserver_impersonation_authorization_attempts_total{decision="denied",mode="user-info"} 1 + ` + + allMetrics := []string{ + namespace + "_" + subsystem + "_attempts_duration_seconds", + namespace + "_" + subsystem + "_attempts_total", + namespace + "_" + subsystem + "_authorization_attempts_duration_seconds", + namespace + "_" + subsystem + "_authorization_attempts_total", + } + + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(expectedValue), allMetrics...); err != nil { + t.Fatal(err) + } +} + +func resetMetricsForTest() { + impersonationAttemptsTotal.Reset() + impersonationAttemptsDurationSeconds.Reset() + impersonationAuthorizationAttemptsTotal.Reset() + impersonationAuthorizationAttemptsDurationSeconds.Reset() +} diff --git a/test/integration/auth/auth_test.go b/test/integration/auth/auth_test.go index 6660254a466..18d3ffe33b4 100644 --- a/test/integration/auth/auth_test.go +++ b/test/integration/auth/auth_test.go @@ -36,6 +36,8 @@ import ( "net/url" "os" "path/filepath" + "regexp" + "slices" "strconv" "strings" "testing" @@ -1110,6 +1112,8 @@ func TestConstrainedImpersonation(t *testing.T) { }) t.Run("bob impersonating alice", func(t *testing.T) { + resetAllMetrics(t, ctx, superuserClient) + impersonatorClientConfig := rest.CopyConfig(kubeConfig) impersonatorClientConfig.BearerToken = "bob" impersonatorClientConfig.Impersonate = rest.ImpersonationConfig{ @@ -1164,9 +1168,29 @@ func TestConstrainedImpersonation(t *testing.T) { if !errors.IsForbidden(err) { t.Fatalf("expected forbidden error, got %T %v", err, err) } + + assertImpersonationMetrics(t, ctx, superuserClient, []string{ + `apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 1`, + `apiserver_impersonation_attempts_duration_seconds_count{decision="denied",mode=""} 3`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} FP`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="denied",mode=""} FP`, + `apiserver_impersonation_attempts_total{decision="allowed",mode="user-info"} 1`, + `apiserver_impersonation_attempts_total{decision="denied",mode=""} 3`, + `apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="user-info"} 2`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="legacy"} 3`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="user-info"} 3`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="user-info"} 2`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="legacy"} 3`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="user-info"} 3`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="user-info"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="legacy"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="user-info"} FP`, + }) }) t.Run("bob impersonating a node", func(t *testing.T) { + resetAllMetrics(t, ctx, superuserClient) + impersonatorClientConfig := rest.CopyConfig(kubeConfig) impersonatorClientConfig.BearerToken = "bob" impersonatorClientConfig.Impersonate = rest.ImpersonationConfig{ @@ -1221,9 +1245,29 @@ func TestConstrainedImpersonation(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %T %v", err, err) } + + assertImpersonationMetrics(t, ctx, superuserClient, []string{ + `apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="arbitrary-node"} 1`, + `apiserver_impersonation_attempts_duration_seconds_count{decision="denied",mode=""} 2`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="arbitrary-node"} FP`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="denied",mode=""} FP`, + `apiserver_impersonation_attempts_total{decision="allowed",mode="arbitrary-node"} 1`, + `apiserver_impersonation_attempts_total{decision="denied",mode=""} 2`, + `apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="arbitrary-node"} 3`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="arbitrary-node"} 2`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="legacy"} 2`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="arbitrary-node"} 3`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="arbitrary-node"} 2`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="legacy"} 2`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="arbitrary-node"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="arbitrary-node"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="legacy"} FP`, + }) }) t.Run("impersonating scheduled node", func(t *testing.T) { + resetAllMetrics(t, ctx, superuserClient) + impersonatorClientConfig := rest.CopyConfig(kubeConfig) impersonatorClientConfig.BearerToken = "serviceaccount2" impersonatorClientConfig.Impersonate = rest.ImpersonationConfig{ @@ -1275,9 +1319,29 @@ func TestConstrainedImpersonation(t *testing.T) { if err != nil { t.Fatalf("expected no error, got %T %v", err, err) } + + assertImpersonationMetrics(t, ctx, superuserClient, []string{ + `apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="associated-node"} 1`, + `apiserver_impersonation_attempts_duration_seconds_count{decision="denied",mode=""} 1`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="associated-node"} FP`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="denied",mode=""} FP`, + `apiserver_impersonation_attempts_total{decision="allowed",mode="associated-node"} 1`, + `apiserver_impersonation_attempts_total{decision="denied",mode=""} 1`, + `apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="associated-node"} 2`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="arbitrary-node"} 1`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="legacy"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="associated-node"} 2`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="arbitrary-node"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="legacy"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="associated-node"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="arbitrary-node"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="legacy"} FP`, + }) }) t.Run("fallback to legacy impersonation", func(t *testing.T) { + resetAllMetrics(t, ctx, superuserClient) + impersonatorClientConfig := rest.CopyConfig(kubeConfig) impersonatorClientConfig.BearerToken = "bob" impersonatorClientConfig.Impersonate = rest.ImpersonationConfig{ @@ -1293,13 +1357,68 @@ func TestConstrainedImpersonation(t *testing.T) { Resources: []string{"users"}, }) - _, err := client.CoreV1().Pods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + _, err := client.CoreV1().Pods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ + LabelSelector: "app=panda", // force this request to have a different cache key than the earlier test + }) if err != nil { t.Fatalf("expected no error, got %T %v", err, err) } + + assertImpersonationMetrics(t, ctx, superuserClient, []string{ + `apiserver_impersonation_attempts_duration_seconds_count{decision="allowed",mode="legacy"} 1`, + `apiserver_impersonation_attempts_duration_seconds_sum{decision="allowed",mode="legacy"} FP`, + `apiserver_impersonation_attempts_total{decision="allowed",mode="legacy"} 1`, + `apiserver_impersonation_authorization_attempts_total{decision="allowed",mode="legacy"} 1`, + `apiserver_impersonation_authorization_attempts_total{decision="denied",mode="user-info"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="allowed",mode="legacy"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_count{decision="denied",mode="user-info"} 1`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="allowed",mode="legacy"} FP`, + `apiserver_impersonation_authorization_attempts_duration_seconds_sum{decision="denied",mode="user-info"} FP`, + }) }) } +func resetAllMetrics(t *testing.T, ctx context.Context, client clientset.Interface) { + t.Helper() + + if err := client.CoreV1().RESTClient().Delete().AbsPath("/metrics").Do(ctx).Error(); err != nil { + t.Fatalf("failed to reset metrics: %v", err) + } +} + +func assertImpersonationMetrics(t *testing.T, ctx context.Context, client clientset.Interface, wantMetricStrings []string) { + t.Helper() + + rc := client.CoreV1().RESTClient() + + body, err := rc.Get().AbsPath("/metrics").DoRaw(ctx) + if err != nil { + t.Fatalf("failed to fetch metrics: %v", err) + } + + var gotMetricStrings []string + trimFP := regexp.MustCompile(`(.*)(} \d+\.\d+.*)`) + for line := range strings.SplitSeq(string(body), "\n") { + if !strings.HasPrefix(line, "apiserver_impersonation_") { + continue + } + // skip histogram bucket lines to keep assertions manageable + if strings.Contains(line, "_bucket{") { + continue + } + if strings.Contains(line, "_seconds_sum") { + line = trimFP.ReplaceAllString(line, `$1`) + "} FP" + } + gotMetricStrings = append(gotMetricStrings, line) + } + slices.Sort(gotMetricStrings) + slices.Sort(wantMetricStrings) + + if diff := cmp.Diff(wantMetricStrings, gotMetricStrings); diff != "" { + t.Errorf("unexpected impersonation metrics diff (-want +got): %s", diff) + } +} + // TestConstrainedImpersonationDisabled tests the impersonation behavior when the // ConstrainedImpersonation feature gate is disabled. In this mode, the legacy // impersonation behavior is expected, where a user only needs the "impersonate"