mirror of
https://github.com/kubernetes/kubernetes.git
synced 2026-05-28 04:04:39 -04:00
Merge pull request #137523 from enj/enj/f/constrained_impersonation_latency_metrics
KEP-5284: add impersonation latency tracking
This commit is contained in:
commit
2757a872ec
6 changed files with 332 additions and 24 deletions
|
|
@ -51,12 +51,17 @@ func WithConstrainedImpersonation(handler http.Handler, a authorizer.Authorizer,
|
|||
recordAuthorizationCall: metrics.RecordImpersonationAuthorizationCall,
|
||||
}
|
||||
|
||||
recordAttempt := func(ctx context.Context, mode, decision string, duration time.Duration) {
|
||||
metrics.RecordImpersonationAttempt(mode, decision, duration)
|
||||
request.TrackImpersonationLatency(ctx, duration)
|
||||
}
|
||||
|
||||
return &constrainedImpersonationHandler{
|
||||
handler: handler,
|
||||
tracker: newImpersonationModesTracker(ma),
|
||||
s: s,
|
||||
|
||||
recordAttempt: metrics.RecordImpersonationAttempt,
|
||||
recordAttempt: recordAttempt,
|
||||
metricsAuthorizer: ma,
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +72,7 @@ type constrainedImpersonationHandler struct {
|
|||
s runtime.NegotiatedSerializer
|
||||
|
||||
// to allow unit tests to override metrics recording
|
||||
recordAttempt func(mode, decision string, duration time.Duration)
|
||||
recordAttempt func(ctx context.Context, mode, decision string, duration time.Duration)
|
||||
metricsAuthorizer *metricsAuthorizer
|
||||
}
|
||||
|
||||
|
|
@ -98,12 +103,12 @@ func (c *constrainedImpersonationHandler) ServeHTTP(w http.ResponseWriter, req *
|
|||
impersonatedUser, err := c.tracker.getImpersonatedUser(ctx, wantedUser, attributes)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
c.recordAttempt("", "denied", duration)
|
||||
c.recordAttempt(ctx, "", "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)
|
||||
c.recordAttempt(ctx, 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))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import (
|
|||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
||||
"k8s.io/apiserver/pkg/audit/policy"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
|
|
@ -49,12 +51,20 @@ type constrainedImpersonationTest struct {
|
|||
constrainedImpersonationHandler *constrainedImpersonationHandler
|
||||
checkedAttrs []authorizer.Attributes
|
||||
echoCalled bool
|
||||
auditEvents []*auditinternal.Event
|
||||
|
||||
attemptMode string
|
||||
attemptDecision string
|
||||
authorizationMetrics map[string]int // "mode/decision" -> count
|
||||
}
|
||||
|
||||
func (c *constrainedImpersonationTest) ProcessEvents(evs ...*auditinternal.Event) bool {
|
||||
for _, e := range evs {
|
||||
c.auditEvents = append(c.auditEvents, e.DeepCopy()) // event is mutated in place so capture its current state
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *constrainedImpersonationTest) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
c.checkedAttrs = append(c.checkedAttrs, a)
|
||||
|
||||
|
|
@ -174,9 +184,11 @@ 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) {
|
||||
recordAttempt := c.constrainedImpersonationHandler.recordAttempt
|
||||
c.constrainedImpersonationHandler.recordAttempt = func(ctx context.Context, mode, decision string, duration time.Duration) {
|
||||
c.attemptMode = mode
|
||||
c.attemptDecision = decision
|
||||
recordAttempt(ctx, mode, decision, duration)
|
||||
}
|
||||
c.constrainedImpersonationHandler.metricsAuthorizer.recordAuthorizationCall = func(mode, decision string, _ time.Duration) {
|
||||
if c.authorizationMetrics == nil {
|
||||
|
|
@ -185,9 +197,20 @@ func (c *constrainedImpersonationTest) handler() http.Handler {
|
|||
c.authorizationMetrics[mode+"/"+decision]++
|
||||
}
|
||||
|
||||
addAuthentication := c.authenticationHandler(addImpersonation)
|
||||
addRequestInfo := c.requestInfoHandler(addAuthentication)
|
||||
return addRequestInfo
|
||||
fakeRuleEvaluator := policy.NewFakePolicyRuleEvaluator(auditinternal.LevelRequestResponse, nil)
|
||||
|
||||
// follow the same handler chain order as DefaultBuildHandlerChain
|
||||
addAudit := filters.WithAudit(addImpersonation, c, fakeRuleEvaluator, nil)
|
||||
addAuthentication := c.authenticationHandler(addAudit)
|
||||
addLatencyTrackers := filters.WithLatencyTrackers(addAuthentication)
|
||||
addRequestInfo := c.requestInfoHandler(addLatencyTrackers)
|
||||
// set received timestamp >500ms in the past so audit annotations are emitted
|
||||
addReceivedTimestamp := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
req = req.WithContext(request.WithReceivedTimestamp(req.Context(), time.Now().Add(-time.Hour)))
|
||||
addRequestInfo.ServeHTTP(w, req)
|
||||
})
|
||||
addAuditInit := filters.WithAuditInit(addReceivedTimestamp)
|
||||
return addAuditInit
|
||||
}
|
||||
|
||||
type testRoundTripper struct {
|
||||
|
|
@ -250,6 +273,25 @@ func comparableAttributes(attributes authorizer.Attributes) authorizer.Attribute
|
|||
}
|
||||
}
|
||||
|
||||
func comparableAuditUser(u *authenticationv1.UserInfo) *user.DefaultInfo {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
var extra map[string][]string
|
||||
if len(u.Extra) > 0 {
|
||||
extra = make(map[string][]string, len(u.Extra))
|
||||
for k, v := range u.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
return &user.DefaultInfo{
|
||||
Name: u.Username,
|
||||
UID: u.UID,
|
||||
Groups: u.Groups,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
func comparableUser(u user.Info) *user.DefaultInfo {
|
||||
return &user.DefaultInfo{
|
||||
Name: u.GetName(),
|
||||
|
|
@ -280,6 +322,36 @@ func (c *constrainedImpersonationTest) assertMetrics(r testRequest) {
|
|||
require.Equal(c.t, r.expectedAuthorizationMetrics, authorizationMetrics, "unexpected authorization metrics")
|
||||
}
|
||||
|
||||
func (c *constrainedImpersonationTest) assertAuditEvents(r testRequest) {
|
||||
c.t.Helper()
|
||||
|
||||
events := c.auditEvents
|
||||
c.auditEvents = nil
|
||||
|
||||
require.Len(c.t, events, 2)
|
||||
|
||||
requestReceived := events[0]
|
||||
responseComplete := events[1]
|
||||
|
||||
require.Empty(c.t, requestReceived.Annotations["apiserver.latency.k8s.io/impersonation"])
|
||||
require.Regexp(c.t, "^[0-9.]+[µnm]s$", responseComplete.Annotations["apiserver.latency.k8s.io/impersonation"])
|
||||
|
||||
require.Nil(c.t, requestReceived.ImpersonatedUser)
|
||||
require.Equal(c.t, r.expectedImpersonatedUser, comparableAuditUser(responseComplete.ImpersonatedUser))
|
||||
|
||||
require.Nil(c.t, requestReceived.ResponseStatus)
|
||||
require.Equal(c.t, r.expectedMessage, responseComplete.ResponseStatus.Message)
|
||||
|
||||
require.Nil(c.t, requestReceived.AuthenticationMetadata)
|
||||
var expectedAuthenticationMetadata *auditinternal.AuthenticationMetadata
|
||||
if r.expectedCode == http.StatusOK && r.expectedAttemptMode != "legacy" {
|
||||
expectedAuthenticationMetadata = &auditinternal.AuthenticationMetadata{
|
||||
ImpersonationConstraint: "impersonate:" + r.expectedAttemptMode,
|
||||
}
|
||||
}
|
||||
require.Equal(c.t, expectedAuthenticationMetadata, responseComplete.AuthenticationMetadata)
|
||||
}
|
||||
|
||||
func (c *constrainedImpersonationTest) assertCache(r testRequest) {
|
||||
rr := require.New(c.t)
|
||||
|
||||
|
|
@ -1222,6 +1294,7 @@ func TestConstrainedImpersonationFilter(t *testing.T) {
|
|||
test.assertAttributes(r)
|
||||
test.assertCache(r)
|
||||
test.assertMetrics(r)
|
||||
test.assertAuditEvents(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@ type LatencyTrackers struct {
|
|||
// When called multiple times, the latency incurred inside to
|
||||
// decode func each time will be summed up.
|
||||
DecodeTracker DurationTracker
|
||||
|
||||
// ImpersonationTracker tracks the latency incurred in resolving impersonation.
|
||||
// This includes mode selection, authorization checks, and cache lookups.
|
||||
ImpersonationTracker DurationTracker
|
||||
}
|
||||
|
||||
type latencyTrackersKeyType int
|
||||
|
|
@ -193,6 +197,7 @@ func WithLatencyTrackersAndCustomClock(parent context.Context, c clock.Clock) co
|
|||
SerializationTracker: newSumLatencyTracker(c),
|
||||
ResponseWriteTracker: newSumLatencyTracker(c),
|
||||
DecodeTracker: newSumLatencyTracker(c),
|
||||
ImpersonationTracker: newSumLatencyTracker(c),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +291,14 @@ func TrackDecodeLatency(ctx context.Context, d time.Duration) {
|
|||
}
|
||||
}
|
||||
|
||||
// TrackImpersonationLatency is used to track latency incurred
|
||||
// in resolving impersonation for the request.
|
||||
func TrackImpersonationLatency(ctx context.Context, d time.Duration) {
|
||||
if tracker, ok := LatencyTrackersFrom(ctx); ok {
|
||||
tracker.ImpersonationTracker.TrackDuration(d)
|
||||
}
|
||||
}
|
||||
|
||||
// AuditAnnotationsFromLatencyTrackers will inspect each latency tracker
|
||||
// associated with the request context and return a set of audit
|
||||
// annotations that can be added to the API audit entry.
|
||||
|
|
@ -301,6 +314,7 @@ func AuditAnnotationsFromLatencyTrackers(ctx context.Context) map[string]string
|
|||
apfQueueWaitLatencyKey = "apiserver.latency.k8s.io/apf-queue-wait"
|
||||
authenticationLatencyKey = "apiserver.latency.k8s.io/authentication"
|
||||
authorizationLatencyKey = "apiserver.latency.k8s.io/authorization"
|
||||
impersonationLatencyKey = "apiserver.latency.k8s.io/impersonation"
|
||||
)
|
||||
|
||||
tracker, ok := LatencyTrackersFrom(ctx)
|
||||
|
|
@ -339,5 +353,8 @@ func AuditAnnotationsFromLatencyTrackers(ctx context.Context) map[string]string
|
|||
if latency := tracker.AuthorizationTracker.GetLatency(); latency != 0 {
|
||||
annotations[authorizationLatencyKey] = latency.String()
|
||||
}
|
||||
if latency := tracker.ImpersonationTracker.GetLatency(); latency != 0 {
|
||||
annotations[impersonationLatencyKey] = latency.String()
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ func Test_ValidateAnnotationsAndWarnings(t *testing.T) {
|
|||
checkExpectedError(t, err, testcase.err)
|
||||
checkFailureReason(t, err, testcase.failureReason)
|
||||
checkExpectedWarnings(t, warnHandler, testcase.warnings)
|
||||
checkAuditEvents(t, logFile, expectedAuditEvents(testcase.auditAnnotations, ns, code), auditAnnotationFilter)
|
||||
checkAuditEvents(t, logFile, expectedAuditEvents(testcase.auditAnnotations, ns, testcase.err, code), auditAnnotationFilter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -3412,7 +3412,7 @@ func (w *warningHandler) HandleWarningHeader(code int, _ string, message string)
|
|||
w.warnings.Insert(message)
|
||||
}
|
||||
|
||||
func expectedAuditEvents(auditAnnotations map[string]string, ns string, code int32) []utils.AuditEvent {
|
||||
func expectedAuditEvents(auditAnnotations map[string]string, ns, msg string, code int32) []utils.AuditEvent {
|
||||
return []utils.AuditEvent{
|
||||
{
|
||||
Level: auditinternal.LevelRequest,
|
||||
|
|
@ -3420,6 +3420,7 @@ func expectedAuditEvents(auditAnnotations map[string]string, ns string, code int
|
|||
RequestURI: fmt.Sprintf("/api/v1/namespaces/%s/configmaps", ns),
|
||||
Verb: "create",
|
||||
Code: code,
|
||||
StatusMessage: msg,
|
||||
User: "system:apiserver",
|
||||
ImpersonatedUser: testReinvocationClientUsername,
|
||||
ImpersonatedGroups: "system:authenticated",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ import (
|
|||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
||||
auditv1 "k8s.io/apiserver/pkg/apis/audit/v1"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authentication/group"
|
||||
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
|
||||
|
|
@ -62,7 +64,9 @@ import (
|
|||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
unionauthz "k8s.io/apiserver/pkg/authorization/union"
|
||||
genericrequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/features"
|
||||
"k8s.io/apiserver/pkg/server"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
webhookutil "k8s.io/apiserver/pkg/util/webhook"
|
||||
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
|
||||
|
|
@ -81,6 +85,7 @@ import (
|
|||
"k8s.io/kubernetes/test/integration"
|
||||
"k8s.io/kubernetes/test/integration/authutil"
|
||||
"k8s.io/kubernetes/test/integration/framework"
|
||||
testutils "k8s.io/kubernetes/test/utils"
|
||||
"k8s.io/kubernetes/test/utils/ktesting"
|
||||
)
|
||||
|
||||
|
|
@ -936,12 +941,17 @@ func TestImpersonateIsForbidden(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestImpersonateWithUID(t *testing.T) {
|
||||
auditPolicyFile, auditLogFile := setupImpersonationAuditFiles(t)
|
||||
server := kubeapiservertesting.StartTestServerOrDie(
|
||||
t,
|
||||
nil,
|
||||
[]string{
|
||||
"--authorization-mode=RBAC",
|
||||
"--anonymous-auth",
|
||||
"--audit-policy-file", auditPolicyFile,
|
||||
"--audit-log-path", auditLogFile,
|
||||
"--audit-log-version", "audit.k8s.io/v1",
|
||||
"--audit-log-mode", "blocking",
|
||||
},
|
||||
framework.SharedEtcd(),
|
||||
)
|
||||
|
|
@ -1003,6 +1013,10 @@ func TestImpersonateWithUID(t *testing.T) {
|
|||
if diff := cmp.Diff(expectedCsrSpec, actualCsrSpec); diff != "" {
|
||||
t.Fatalf("CSR spec was different than expected, -got, +want:\n %s", diff)
|
||||
}
|
||||
|
||||
withUID := allowedImpersonationEvent("create", http.StatusCreated, "alice", "system:authenticated", "certificatesigningrequests", nil)
|
||||
withUID.ImpersonatedUID = "1234"
|
||||
assertImpersonationAuditEvents(t, auditLogFile, user.APIServerUser, withUID)
|
||||
})
|
||||
|
||||
t.Run("impersonation with only UID fails", func(t *testing.T) {
|
||||
|
|
@ -1026,6 +1040,7 @@ func TestImpersonateWithUID(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("impersonating UID without authorization fails", func(t *testing.T) {
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
adminClient := clientset.NewForConfigOrDie(server.ClientConfig)
|
||||
|
||||
authutil.GrantUserAuthorization(t, ctx, adminClient, "system:anonymous",
|
||||
|
|
@ -1056,6 +1071,10 @@ func TestImpersonateWithUID(t *testing.T) {
|
|||
); diff != "" {
|
||||
t.Fatalf("forbidden error different than expected, -got, +want:\n %s", diff)
|
||||
}
|
||||
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "system:anonymous",
|
||||
deniedImpersonationEvent("list", `uids.authentication.k8s.io "1234" is forbidden: User "system:anonymous" cannot impersonate resource "uids" in API group "authentication.k8s.io" at the cluster scope`, "nodes"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1087,12 +1106,17 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
t.Cleanup(cancel)
|
||||
|
||||
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ConstrainedImpersonation, true)
|
||||
var auditLogFile string
|
||||
_, kubeConfig, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{
|
||||
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
|
||||
opts.Authorization.Modes = []string{"RBAC"}
|
||||
auditLogFile = setupImpersonationAudit(t, opts)
|
||||
},
|
||||
ModifyServerConfig: func(config *controlplane.Config) {
|
||||
config.ControlPlane.Generic.Authentication.Authenticator = authenticator
|
||||
config.ControlPlane.Generic.RequestInfoResolver = &slowImpersonationRequests{
|
||||
delegate: server.NewRequestInfoResolver(config.ControlPlane.Generic),
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(tearDownFn)
|
||||
|
|
@ -1113,6 +1137,7 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
|
||||
t.Run("bob impersonating alice", func(t *testing.T) {
|
||||
resetAllMetrics(t, ctx, superuserClient)
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
|
||||
impersonatorClientConfig := rest.CopyConfig(kubeConfig)
|
||||
impersonatorClientConfig.BearerToken = "bob"
|
||||
|
|
@ -1186,10 +1211,17 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
`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`,
|
||||
})
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "bob",
|
||||
deniedImpersonationEvent("list", `pods is forbidden: User "bob" cannot impersonate-on:user-info:list resource "pods" in API group "" at the cluster scope`, "pods"),
|
||||
deniedImpersonationEvent("list", `pods is forbidden: User "bob" cannot impersonate-on:user-info:list resource "pods" in API group "" at the cluster scope`, "pods"),
|
||||
allowedImpersonationEvent("list", http.StatusOK, "alice", "system:authenticated", "pods", new("impersonate:user-info")),
|
||||
deniedImpersonationEvent("watch", `pods is forbidden: User "bob" cannot impersonate-on:user-info:watch resource "pods" in API group "" at the cluster scope`, "pods"),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("bob impersonating a node", func(t *testing.T) {
|
||||
resetAllMetrics(t, ctx, superuserClient)
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
|
||||
impersonatorClientConfig := rest.CopyConfig(kubeConfig)
|
||||
impersonatorClientConfig.BearerToken = "bob"
|
||||
|
|
@ -1263,10 +1295,16 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
`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`,
|
||||
})
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "bob",
|
||||
deniedImpersonationEvent("list", `pods is forbidden: User "bob" cannot impersonate-on:arbitrary-node:list resource "pods" in API group "" at the cluster scope`, "pods"),
|
||||
deniedImpersonationEvent("list", `nodes.authentication.k8s.io "node1" is forbidden: User "bob" cannot impersonate:arbitrary-node resource "nodes" in API group "authentication.k8s.io" at the cluster scope`, "pods"),
|
||||
allowedImpersonationEvent("list", http.StatusOK, "system:node:node1", "system:authenticated,system:nodes", "pods", new("impersonate:arbitrary-node")),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("impersonating scheduled node", func(t *testing.T) {
|
||||
resetAllMetrics(t, ctx, superuserClient)
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
|
||||
impersonatorClientConfig := rest.CopyConfig(kubeConfig)
|
||||
impersonatorClientConfig.BearerToken = "serviceaccount2"
|
||||
|
|
@ -1337,10 +1375,17 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
`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`,
|
||||
})
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "system:serviceaccount:default:sa2",
|
||||
deniedImpersonationEvent("list", `pods is forbidden: User "system:serviceaccount:default:sa2" cannot impersonate-on:arbitrary-node:list resource "pods" in API group "" at the cluster scope`, "pods"),
|
||||
)
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "system:serviceaccount:default:sa1",
|
||||
allowedImpersonationEvent("list", http.StatusOK, "system:node:node1", "system:authenticated,system:nodes", "pods", new("impersonate:associated-node")),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("fallback to legacy impersonation", func(t *testing.T) {
|
||||
resetAllMetrics(t, ctx, superuserClient)
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
|
||||
impersonatorClientConfig := rest.CopyConfig(kubeConfig)
|
||||
impersonatorClientConfig.BearerToken = "bob"
|
||||
|
|
@ -1375,6 +1420,9 @@ func TestConstrainedImpersonation(t *testing.T) {
|
|||
`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`,
|
||||
})
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "bob",
|
||||
allowedImpersonationEvent("list", http.StatusOK, "alice", "system:authenticated", "pods", nil),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1419,6 +1467,147 @@ func assertImpersonationMetrics(t *testing.T, ctx context.Context, client client
|
|||
}
|
||||
}
|
||||
|
||||
const impersonationAuditPolicy = `
|
||||
apiVersion: audit.k8s.io/v1
|
||||
kind: Policy
|
||||
omitStages:
|
||||
- "RequestReceived"
|
||||
rules:
|
||||
- level: Metadata
|
||||
`
|
||||
|
||||
func setupImpersonationAuditFiles(t *testing.T) (policyFilePath, logFilePath string) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
policyFilePath = filepath.Join(dir, "audit-policy.yaml")
|
||||
if err := os.WriteFile(policyFilePath, []byte(impersonationAuditPolicy), 0644); err != nil {
|
||||
t.Fatalf("failed to write audit policy: %v", err)
|
||||
}
|
||||
|
||||
logFilePath = filepath.Join(dir, "audit.log")
|
||||
|
||||
return policyFilePath, logFilePath
|
||||
}
|
||||
|
||||
func setupImpersonationAudit(t *testing.T, opts *options.ServerRunOptions) string {
|
||||
t.Helper()
|
||||
|
||||
policyFilePath, logFilePath := setupImpersonationAuditFiles(t)
|
||||
opts.Audit.PolicyFile = policyFilePath
|
||||
opts.Audit.LogOptions.Path = logFilePath
|
||||
opts.Audit.LogOptions.GroupVersionString = "audit.k8s.io/v1"
|
||||
opts.Audit.LogOptions.BatchOptions.Mode = "blocking"
|
||||
|
||||
return logFilePath
|
||||
}
|
||||
|
||||
func truncateAuditLog(t *testing.T, logFilePath string) {
|
||||
t.Helper()
|
||||
if err := os.Truncate(logFilePath, 0); err != nil {
|
||||
t.Fatalf("failed to truncate audit log: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAuditEvents(t *testing.T, logFilePath string) []testutils.AuditEvent {
|
||||
t.Helper()
|
||||
stream, err := os.Open(logFilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open audit log: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Errorf("failed to close audit log: %v", err)
|
||||
}
|
||||
}()
|
||||
report, err := testutils.CheckAuditLinesFiltered(stream, nil, auditv1.SchemeGroupVersion, func(_, _ string) bool {
|
||||
return true // get all audit annotations
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse audit log: %v", err)
|
||||
}
|
||||
return report.AllEvents
|
||||
}
|
||||
|
||||
func assertImpersonationAuditEvents(t *testing.T, logFilePath, wantUser string, wantEvents ...testutils.AuditEvent) {
|
||||
t.Helper()
|
||||
|
||||
latencyPattern := regexp.MustCompile("^[0-9.]+[µnm]s$")
|
||||
|
||||
var matched []testutils.AuditEvent
|
||||
for _, event := range getAuditEvents(t, logFilePath) {
|
||||
if event.Stage != auditinternal.StageResponseComplete {
|
||||
continue
|
||||
}
|
||||
if event.User != wantUser {
|
||||
continue
|
||||
}
|
||||
if len(event.ImpersonatedUser) == 0 && !strings.Contains(event.StatusMessage, "impersonate") {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, event)
|
||||
}
|
||||
if len(matched) != len(wantEvents) {
|
||||
t.Fatalf("expected %d audit event(s) from user %q with impersonation, got %d: %v", len(wantEvents), wantUser, len(matched), matched)
|
||||
}
|
||||
for i, event := range matched {
|
||||
got := testutils.AuditEvent{
|
||||
Verb: event.Verb,
|
||||
Code: event.Code,
|
||||
StatusMessage: event.StatusMessage,
|
||||
ImpersonatedUser: event.ImpersonatedUser,
|
||||
ImpersonatedUID: event.ImpersonatedUID,
|
||||
ImpersonatedGroups: event.ImpersonatedGroups,
|
||||
Resource: event.Resource,
|
||||
Namespace: event.Namespace,
|
||||
AuthorizeDecision: event.AuthorizeDecision,
|
||||
ImpersonationConstraint: event.ImpersonationConstraint,
|
||||
}
|
||||
if diff := cmp.Diff(wantEvents[i], got); len(diff) > 0 {
|
||||
t.Errorf("audit event[%d] mismatch (-want +got): %s", i, diff)
|
||||
}
|
||||
if event.Verb != "watch" && utilfeature.DefaultFeatureGate.Enabled(features.ConstrainedImpersonation) {
|
||||
latency := event.CustomAuditAnnotations["apiserver.latency.k8s.io/impersonation"]
|
||||
if !latencyPattern.MatchString(latency) {
|
||||
t.Errorf("audit event[%d] expected valid impersonation latency annotation, got %q", i, latency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allowedImpersonationEvent(verb string, code int32, impersonatedUser, impersonatedGroups, resource string, constraint *string) testutils.AuditEvent {
|
||||
return testutils.AuditEvent{
|
||||
Verb: verb,
|
||||
Code: code,
|
||||
ImpersonatedUser: impersonatedUser,
|
||||
ImpersonatedGroups: impersonatedGroups,
|
||||
Resource: resource,
|
||||
AuthorizeDecision: "allow",
|
||||
ImpersonationConstraint: constraint,
|
||||
}
|
||||
}
|
||||
|
||||
func deniedImpersonationEvent(verb, statusMessage, resource string) testutils.AuditEvent {
|
||||
return testutils.AuditEvent{
|
||||
Verb: verb,
|
||||
Code: http.StatusForbidden,
|
||||
StatusMessage: statusMessage,
|
||||
Resource: resource,
|
||||
}
|
||||
}
|
||||
|
||||
type slowImpersonationRequests struct {
|
||||
delegate genericrequest.RequestInfoResolver
|
||||
}
|
||||
|
||||
func (s *slowImpersonationRequests) NewRequestInfo(req *http.Request) (*genericrequest.RequestInfo, error) {
|
||||
if len(req.Header.Get(authenticationv1.ImpersonateUserHeader)) > 0 {
|
||||
time.Sleep(505 * time.Millisecond) // force latency audit annotations to be emitted for impersonation requests
|
||||
}
|
||||
return s.delegate.NewRequestInfo(req)
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
|
@ -1441,9 +1630,11 @@ func TestConstrainedImpersonationDisabled(t *testing.T) {
|
|||
t.Cleanup(cancel)
|
||||
|
||||
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ConstrainedImpersonation, false)
|
||||
var auditLogFile string
|
||||
_, kubeConfig, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{
|
||||
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
|
||||
opts.Authorization.Modes = []string{"RBAC"}
|
||||
auditLogFile = setupImpersonationAudit(t, opts)
|
||||
},
|
||||
ModifyServerConfig: func(config *controlplane.Config) {
|
||||
config.ControlPlane.Generic.Authentication.Authenticator = authenticator
|
||||
|
|
@ -1466,6 +1657,8 @@ func TestConstrainedImpersonationDisabled(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("bob impersonating alice", func(t *testing.T) {
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
|
||||
authutil.GrantUserAuthorization(t, ctx, superuserClient, "bob", rbacv1.PolicyRule{
|
||||
Verbs: []string{"impersonate:user-info"},
|
||||
APIGroups: []string{"authentication.k8s.io"},
|
||||
|
|
@ -1507,9 +1700,15 @@ func TestConstrainedImpersonationDisabled(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("expected no error, got %T %v", err, err)
|
||||
}
|
||||
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "bob",
|
||||
deniedImpersonationEvent("list", `users "alice" is forbidden: User "bob" cannot impersonate resource "users" in API group "" at the cluster scope`, "pods"),
|
||||
allowedImpersonationEvent("list", http.StatusOK, "alice", "system:authenticated", "pods", nil),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("serviceaccount impersonating a node", func(t *testing.T) {
|
||||
truncateAuditLog(t, auditLogFile)
|
||||
authutil.GrantUserAuthorization(t, ctx, superuserClient, "system:serviceaccount:default:sa1", rbacv1.PolicyRule{
|
||||
Verbs: []string{"impersonate:associated-node"},
|
||||
APIGroups: []string{"authentication.k8s.io"},
|
||||
|
|
@ -1558,6 +1757,11 @@ func TestConstrainedImpersonationDisabled(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("expected no error, got %T %v", err, err)
|
||||
}
|
||||
|
||||
assertImpersonationAuditEvents(t, auditLogFile, "system:serviceaccount:default:sa1",
|
||||
deniedImpersonationEvent("list", `users "system:node:node1" is forbidden: User "system:serviceaccount:default:sa1" cannot impersonate resource "users" in API group "" at the cluster scope`, "pods"),
|
||||
allowedImpersonationEvent("list", http.StatusOK, "system:node:node1", "system:authenticated", "pods", nil),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,20 +34,23 @@ import (
|
|||
|
||||
// AuditEvent is a simplified representation of an audit event for testing purposes
|
||||
type AuditEvent struct {
|
||||
ID types.UID
|
||||
Level auditinternal.Level
|
||||
Stage auditinternal.Stage
|
||||
RequestURI string
|
||||
Verb string
|
||||
Code int32
|
||||
User string
|
||||
ImpersonatedUser string
|
||||
ImpersonatedGroups string
|
||||
Resource string
|
||||
Namespace string
|
||||
RequestObject bool
|
||||
ResponseObject bool
|
||||
AuthorizeDecision string
|
||||
ID types.UID
|
||||
Level auditinternal.Level
|
||||
Stage auditinternal.Stage
|
||||
RequestURI string
|
||||
Verb string
|
||||
Code int32
|
||||
StatusMessage string
|
||||
User string
|
||||
ImpersonatedUser string
|
||||
ImpersonatedUID string
|
||||
ImpersonatedGroups string
|
||||
ImpersonationConstraint *string
|
||||
Resource string
|
||||
Namespace string
|
||||
RequestObject bool
|
||||
ResponseObject bool
|
||||
AuthorizeDecision string
|
||||
|
||||
// The Check functions in this package takes ownerships of these maps. You should
|
||||
// not reference these maps after calling the Check functions.
|
||||
|
|
@ -147,6 +150,7 @@ func testEventFromInternalFiltered(e *auditinternal.Event, customAnnotationsFilt
|
|||
}
|
||||
if e.ResponseStatus != nil {
|
||||
event.Code = e.ResponseStatus.Code
|
||||
event.StatusMessage = e.ResponseStatus.Message
|
||||
}
|
||||
if e.ResponseObject != nil {
|
||||
event.ResponseObject = true
|
||||
|
|
@ -156,9 +160,13 @@ func testEventFromInternalFiltered(e *auditinternal.Event, customAnnotationsFilt
|
|||
}
|
||||
if e.ImpersonatedUser != nil {
|
||||
event.ImpersonatedUser = e.ImpersonatedUser.Username
|
||||
event.ImpersonatedUID = e.ImpersonatedUser.UID
|
||||
sort.Strings(e.ImpersonatedUser.Groups)
|
||||
event.ImpersonatedGroups = strings.Join(e.ImpersonatedUser.Groups, ",")
|
||||
}
|
||||
if e.AuthenticationMetadata != nil {
|
||||
event.ImpersonationConstraint = &e.AuthenticationMetadata.ImpersonationConstraint
|
||||
}
|
||||
event.AuthorizeDecision = e.Annotations["authorization.k8s.io/decision"]
|
||||
for k, v := range e.Annotations {
|
||||
if strings.HasPrefix(k, mutating.PatchAuditAnnotationPrefix) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue