KEP-5284: add constrained impersonation metrics

See https://kep.k8s.io/5284 for details.

apiserver_impersonation_attempts_total{mode, decision}
apiserver_impersonation_attempts_duration_seconds{mode, decision}
apiserver_impersonation_authorization_attempts_total{mode, decision}
apiserver_impersonation_authorization_attempts_duration_seconds{mode, decision}

Signed-off-by: Monis Khan <mok@microsoft.com>
This commit is contained in:
Monis Khan 2026-02-27 11:33:08 -05:00
parent ca6034ad9a
commit ba2a68e1db
No known key found for this signature in database
5 changed files with 873 additions and 48 deletions

View file

@ -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"
}

View file

@ -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)
}
})
}

View file

@ -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())
}

View file

@ -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()
}

View file

@ -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"