diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go index 0f054236ad5..e2a6e953d5e 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation.go @@ -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)) diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go index df16b254574..614a3636421 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/filters/impersonation/constrained_impersonation_test.go @@ -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) } }) } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/request/webhook_duration.go b/staging/src/k8s.io/apiserver/pkg/endpoints/request/webhook_duration.go index bda43b617b7..df0ff77d893 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/request/webhook_duration.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/request/webhook_duration.go @@ -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 } diff --git a/test/integration/apiserver/cel/validatingadmissionpolicy_test.go b/test/integration/apiserver/cel/validatingadmissionpolicy_test.go index 970b77e569f..d8204809d7c 100644 --- a/test/integration/apiserver/cel/validatingadmissionpolicy_test.go +++ b/test/integration/apiserver/cel/validatingadmissionpolicy_test.go @@ -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", diff --git a/test/integration/auth/auth_test.go b/test/integration/auth/auth_test.go index 18d3ffe33b4..5e74242e043 100644 --- a/test/integration/auth/auth_test.go +++ b/test/integration/auth/auth_test.go @@ -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), + ) }) } diff --git a/test/utils/audit.go b/test/utils/audit.go index cc5bf1338a5..11264b37068 100644 --- a/test/utils/audit.go +++ b/test/utils/audit.go @@ -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) {