From 4f9530eec7d1a36db6e0831ff6d7829d724adbd5 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 5 Jul 2024 11:06:31 -0700 Subject: [PATCH 01/71] audit logging WIP --- internal/concierge/apiserver/apiserver.go | 2 +- .../supervisorstorage/garbage_collector.go | 83 +++++++++-- .../garbage_collector_test.go | 3 + .../downstreamsession/downstream_session.go | 29 +++- .../endpoints/auth/auth_handler.go | 45 +++++- .../endpoints/auth/auth_handler_test.go | 3 + .../endpoints/callback/callback_handler.go | 5 +- .../callback/callback_handler_test.go | 11 +- .../endpoints/login/post_login_handler.go | 11 +- .../login/post_login_handler_test.go | 3 +- .../endpoints/token/token_handler.go | 63 +++++--- .../endpoints/token/token_handler_test.go | 2 + .../endpoints/tokenexchange/token_exchange.go | 41 +++-- .../endpointsmanager/manager.go | 67 ++++++--- .../endpointsmanager/manager_test.go | 25 ++-- .../requestlogger/request_logger.go | 141 ++++++++++++++++++ internal/plog/audit_event.go | 47 ++++++ internal/plog/audit_event_test.go | 75 ++++++++++ internal/plog/plog.go | 104 +++++++++++++ internal/registry/credentialrequest/rest.go | 16 +- .../registry/credentialrequest/rest_test.go | 31 ++-- internal/supervisor/server/server.go | 6 +- 22 files changed, 704 insertions(+), 109 deletions(-) create mode 100644 internal/federationdomain/requestlogger/request_logger.go create mode 100644 internal/plog/audit_event.go create mode 100644 internal/plog/audit_event_test.go diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index 1c7bf3f04..184b5e00b 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -82,7 +82,7 @@ func (c completedConfig) New() (*PinnipedServer, error) { for _, f := range []func() (schema.GroupVersionResource, rest.Storage){ func() (schema.GroupVersionResource, rest.Storage) { tokenCredReqGVR := c.ExtraConfig.LoginConciergeGroupVersion.WithResource("tokencredentialrequests") - tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource()) + tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource(), plog.New()) return tokenCredReqGVR, tokenCredStorage }, func() (schema.GroupVersionResource, rest.Storage) { diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 0ae8ac5ca..0ed723197 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -9,6 +9,7 @@ import ( "fmt" "time" + "github.com/ory/fosite" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -35,10 +36,12 @@ import ( const minimumRepeatInterval = 30 * time.Second type garbageCollectorController struct { - idpCache UpstreamOIDCIdentityProviderICache - secretInformer corev1informers.SecretInformer - kubeClient kubernetes.Interface - clock clock.Clock + idpCache UpstreamOIDCIdentityProviderICache + secretInformer corev1informers.SecretInformer + kubeClient kubernetes.Interface + clock clock.Clock + auditLogger plog.AuditLogger + timeOfMostRecentSweep time.Time } @@ -53,6 +56,7 @@ func GarbageCollectorController( kubeClient kubernetes.Interface, secretInformer corev1informers.SecretInformer, withInformer pinnipedcontroller.WithInformerOptionFunc, + auditLogger plog.AuditLogger, ) controllerlib.Controller { isSecretWithGCAnnotation := func(obj metav1.Object) bool { secret, ok := obj.(*corev1.Secret) @@ -70,6 +74,7 @@ func GarbageCollectorController( secretInformer: secretInformer, kubeClient: kubeClient, clock: clock, + auditLogger: auditLogger, }, }, withInformer( @@ -163,6 +168,7 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { plog.WarningErr("failed to garbage collect resource", err, logKV(secret)...) continue } + c.maybeAuditLogGC(storageType, secret) plog.Info("storage garbage collector deleted resource", logKV(secret)...) } @@ -192,7 +198,10 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co return nil } // When the downstream authcode was never used, then its storage must contain the latest upstream token. - return c.tryRevokeUpstreamOIDCToken(ctx, authorizeCodeSession.Request.Session.(*psession.PinnipedSession).Custom, secret) + return c.tryRevokeUpstreamOIDCToken(ctx, + authorizeCodeSession.Request.Session.(*psession.PinnipedSession).Custom, + authorizeCodeSession.Request, + secret) case accesstoken.TypeLabelValue: // For access token storage, check if the "offline_access" scope was granted on the downstream session. @@ -203,11 +212,13 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co if err != nil { return err } - pinnipedSession := accessTokenSession.Request.Session.(*psession.PinnipedSession) if accessTokenSession.Request.GetGrantedScopes().Has(oidcapi.ScopeOfflineAccess) { return nil } - return c.tryRevokeUpstreamOIDCToken(ctx, pinnipedSession.Custom, secret) + return c.tryRevokeUpstreamOIDCToken(ctx, + accessTokenSession.Request.Session.(*psession.PinnipedSession).Custom, + accessTokenSession.Request, + secret) case refreshtoken.TypeLabelValue: // For refresh token storage, always revoke its upstream token. This refresh token storage could be @@ -217,7 +228,10 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co if err != nil { return err } - return c.tryRevokeUpstreamOIDCToken(ctx, refreshTokenSession.Request.Session.(*psession.PinnipedSession).Custom, secret) + return c.tryRevokeUpstreamOIDCToken(ctx, + refreshTokenSession.Request.Session.(*psession.PinnipedSession).Custom, + refreshTokenSession.Request, + secret) case pkce.TypeLabelValue: // For PKCE storage, its very existence means that the downstream authcode was never exchanged, because @@ -237,7 +251,12 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co } } -func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Context, customSessionData *psession.CustomSessionData, secret *corev1.Secret) error { +func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( + ctx context.Context, + customSessionData *psession.CustomSessionData, + request *fosite.Request, + secret *corev1.Secret, +) error { // When session was for another upstream IDP type, e.g. LDAP, there is no upstream OIDC token involved. if customSessionData.ProviderType != psession.ProviderTypeOIDC { return nil @@ -264,6 +283,8 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont if err != nil { return err } + c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, nil, request, + "type", upstreamprovider.RefreshTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC refresh token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -272,12 +293,56 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont if err != nil { return err } + c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, nil, request, + "type", upstreamprovider.AccessTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC access token (or provider has no revocation endpoint)", logKV(secret)...) } return nil } +func (c *garbageCollectorController) maybeAuditLogGC(storageType string, secret *corev1.Secret) { + r, err := c.requestFromSecret(storageType, secret) + if err == nil && r != nil { + c.auditLogger.Audit(plog.AuditEventSessionGarbageCollected, nil, r, "storageType", storageType) + } +} + +func (c *garbageCollectorController) requestFromSecret(storageType string, secret *corev1.Secret) (*fosite.Request, error) { + switch storageType { + case authorizationcode.TypeLabelValue: + authorizeCodeSession, err := authorizationcode.ReadFromSecret(secret) + if err != nil { + return nil, err + } + return authorizeCodeSession.Request, nil + + case accesstoken.TypeLabelValue: + accessTokenSession, err := accesstoken.ReadFromSecret(secret) + if err != nil { + return nil, err + } + return accessTokenSession.Request, nil + + case refreshtoken.TypeLabelValue: + refreshTokenSession, err := refreshtoken.ReadFromSecret(secret) + if err != nil { + return nil, err + } + return refreshTokenSession.Request, nil + + case pkce.TypeLabelValue: + return nil, nil // if this still exists, then it means that the user never exchanged their authcode + + case openidconnect.TypeLabelValue: + return nil, nil // if this still exists, then it means that the user never exchanged their authcode + + default: + // There are no other storage types, so this should never happen in practice. + return nil, errors.New("garbage collector saw invalid label on Secret when trying to determine session ID") + } +} + func logKV(secret *corev1.Secret) []any { return []any{ "secretName", secret.Name, diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index 903ee7753..436819e15 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -31,6 +31,7 @@ import ( "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/refreshtoken" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -55,6 +56,7 @@ func TestGarbageCollectorControllerInformerFilters(t *testing.T) { nil, secretsInformer, observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters + plog.New(), ) secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer) }) @@ -148,6 +150,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { kubeClient, kubeInformers.Core().V1().Secrets(), controllerlib.WithInformer, + plog.New(), ) // Set this at the last second to support calling subject.Name(). diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 512557afd..5097a6adf 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -34,20 +34,30 @@ type SessionConfig struct { ClientID string // The scopes that were granted for the new downstream session. GrantedScopes []string + // The identity provider used to authenticate the user. + IdentityProvider resolvedprovider.FederationDomainResolvedIdentityProvider + // The fosite Requester that is starting this session. + SessionIDGetter plog.SessionIDGetter } // NewPinnipedSession applies the configured FederationDomain identity transformations // and creates a downstream Pinniped session. func NewPinnipedSession( ctx context.Context, - idp resolvedprovider.FederationDomainResolvedIdentityProvider, + auditLogger plog.AuditLogger, c *SessionConfig, ) (*psession.PinnipedSession, error) { now := time.Now().UTC() + auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, c.SessionIDGetter, + "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, + "upstreamGroups", c.UpstreamIdentity.UpstreamGroups) + downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, - idp.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) + c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { + auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, c.SessionIDGetter, + "err", err) return nil, err } @@ -55,12 +65,12 @@ func NewPinnipedSession( Username: downstreamUsername, UpstreamUsername: c.UpstreamIdentity.UpstreamUsername, UpstreamGroups: c.UpstreamIdentity.UpstreamGroups, - ProviderUID: idp.GetProvider().GetResourceUID(), - ProviderName: idp.GetProvider().GetResourceName(), - ProviderType: idp.GetSessionProviderType(), + ProviderUID: c.IdentityProvider.GetProvider().GetResourceUID(), + ProviderName: c.IdentityProvider.GetProvider().GetResourceName(), + ProviderType: c.IdentityProvider.GetSessionProviderType(), Warnings: c.UpstreamLoginExtras.Warnings, } - idp.ApplyIDPSpecificSessionDataToSession(customSessionData, c.UpstreamIdentity.IDPSpecificSessionData) + c.IdentityProvider.ApplyIDPSpecificSessionDataToSession(customSessionData, c.UpstreamIdentity.IDPSpecificSessionData) pinnipedSession := &psession.PinnipedSession{ Fosite: &openid.DefaultSession{ @@ -94,6 +104,13 @@ func NewPinnipedSession( pinnipedSession.IDTokenClaims().Extra = extras + auditLogger.Audit(plog.AuditEventSessionStarted, ctx, c.SessionIDGetter, + "username", downstreamUsername, + "groups", downstreamGroups, + "subject", c.UpstreamIdentity.DownstreamSubject, + "additionalClaims", c.UpstreamLoginExtras.DownstreamAdditionalClaims, + "warnings", c.UpstreamLoginExtras.Warnings) + return pinnipedSession, nil } diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index aa36835c9..a0e75bb46 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -13,6 +13,7 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/openid" fositejwt "github.com/ory/fosite/token/jwt" + "k8s.io/apimachinery/pkg/util/sets" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/federationdomain/csrftoken" @@ -34,6 +35,21 @@ const ( promptParamNone = "none" ) +//nolint:gochecknoglobals // please treat this as a readonly const, do not mutate +var paramsSafeToLog = sets.New[string]( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html, some of which are ignored. + // Redacting state and nonce params, in case they contain any info that the client considers sensitive. + "scope", "response_type", "client_id", "redirect_uri", "response_mode", "display", "prompt", + "max_age", "ui_locales", "id_token_hint", "login_hint", "acr_values", "claims_locales", "claims", + "request", "request_uri", "registration", + // PKCE params from https://datatracker.ietf.org/doc/html/rfc7636. Let code_challenge be redacted. + "code_challenge_method", + // Custom Pinniped authorization params. + oidcapi.AuthorizeUpstreamIDPNameParamName, oidcapi.AuthorizeUpstreamIDPTypeParamName, + // Google-specific param that some client libraries will send anyway. Ignored by Pinniped but safe to log. + "access_type", +) + type authorizeHandler struct { downstreamIssuerURL string idpFinder federationdomainproviders.FederationDomainIdentityProvidersFinderI @@ -44,6 +60,7 @@ type authorizeHandler struct { generateNonce func() (nonce.Nonce, error) upstreamStateEncoder oidc.Encoder cookieCodec oidc.Codec + auditLogger plog.AuditLogger } func NewHandler( @@ -56,6 +73,7 @@ func NewHandler( generateNonce func() (nonce.Nonce, error), upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, + auditLogger plog.AuditLogger, ) http.Handler { h := &authorizeHandler{ downstreamIssuerURL: downstreamIssuerURL, @@ -67,6 +85,7 @@ func NewHandler( generateNonce: generateNonce, upstreamStateEncoder: upstreamStateEncoder, cookieCodec: cookieCodec, + auditLogger: auditLogger, } // During a response_mode=form_post auth request using the browser flow, the custom form_post html page may // be used to post certain errors back to the CLI from this handler's response, so allow the form_post @@ -83,9 +102,10 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // The client set a username or password header, so they are trying to log in without using a browser. - requestedBrowserlessFlow := len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || - len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 + // If the client set a username or password header, they are trying to log in without using a browser. + hadUsernameHeader := len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 + hadPasswordHeader := len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 + requestedBrowserlessFlow := hadUsernameHeader || hadPasswordHeader // Need to parse the request params, so we can get the IDP name. The style and text of the error is inspired by // fosite's implementation of NewAuthorizeRequest(). Fosite only calls ParseMultipartForm() there. However, @@ -112,6 +132,15 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Log if these headers were present, but don't log the actual values. The password is obviously sensitive, + // and sometimes users use their password as their username by mistake. + h.auditLogger.Audit(plog.AuditEventHTTPRequestCustomHeadersUsed, r.Context(), nil, + oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader, + oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) + + h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), nil, + "params", plog.SanitizeParams(r.Form, paramsSafeToLog)) + // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and // oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP. // The Pinniped CLI has been sending these params since v0.9.0. @@ -141,6 +170,12 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + h.auditLogger.Audit(plog.AuditEventUsingUpstreamIDP, r.Context(), nil, + "displayName", idp.GetDisplayName(), + "resourceName", idp.GetProvider().GetResourceName(), + "resourceUID", idp.GetProvider().GetResourceUID(), + "type", idp.GetSessionProviderType()) + h.authorize(w, r, requestedBrowserlessFlow, idp) } @@ -203,11 +238,13 @@ func (h *authorizeHandler) authorizeWithoutBrowser( return err } - session, err := downstreamsession.NewPinnipedSession(r.Context(), idp, &downstreamsession.SessionConfig{ + session, err := downstreamsession.NewPinnipedSession(r.Context(), h.auditLogger, &downstreamsession.SessionConfig{ UpstreamIdentity: identity, UpstreamLoginExtras: loginExtras, ClientID: authorizeRequester.GetClient().GetID(), GrantedScopes: authorizeRequester.GetGrantedScopes(), + IdentityProvider: idp, + SessionIDGetter: authorizeRequester, }) if err != nil { return fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 8b3aed110..5bffb75dd 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -36,6 +36,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -3624,6 +3625,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, + plog.New(), ) runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient) }) @@ -3647,6 +3649,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, + plog.New(), ) runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient) diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 9295b90ef..5c8d32165 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -24,6 +24,7 @@ func NewHandler( oauthHelper fosite.OAuth2Provider, stateDecoder, cookieDecoder oidc.Decoder, redirectURI string, + auditLogger plog.AuditLogger, ) http.Handler { handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { state, err := validateRequest(r, stateDecoder, cookieDecoder) @@ -69,11 +70,13 @@ func NewHandler( return err } - session, err := downstreamsession.NewPinnipedSession(r.Context(), idp, &downstreamsession.SessionConfig{ + session, err := downstreamsession.NewPinnipedSession(r.Context(), auditLogger, &downstreamsession.SessionConfig{ UpstreamIdentity: identity, UpstreamLoginExtras: loginExtras, ClientID: authorizeRequester.GetClient().GetID(), GrantedScopes: authorizeRequester.GetGrantedScopes(), + IdentityProvider: idp, + SessionIDGetter: authorizeRequester, }) if err != nil { plog.WarningErr("unable to create a Pinniped session", err, diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 2dd34d078..12e6d65e9 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -27,6 +27,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -1757,7 +1758,15 @@ func TestCallbackEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) - subject := NewHandler(test.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) + subject := NewHandler( + test.idps.BuildFederationDomainIdentityProvidersListerFinder(), + oauthHelper, + happyStateCodec, + happyCookieCodec, + happyUpstreamRedirectURI, + plog.New(), + ) + reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") req := httptest.NewRequest(test.method, test.path, nil).WithContext(reqContext) if test.csrfCookie != "" { diff --git a/internal/federationdomain/endpoints/login/post_login_handler.go b/internal/federationdomain/endpoints/login/post_login_handler.go index 07feb9f5e..084a530ae 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler.go +++ b/internal/federationdomain/endpoints/login/post_login_handler.go @@ -19,7 +19,12 @@ import ( "go.pinniped.dev/internal/plog" ) -func NewPostHandler(issuerURL string, upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersFinderI, oauthHelper fosite.OAuth2Provider) HandlerFunc { +func NewPostHandler( + issuerURL string, + upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersFinderI, + oauthHelper fosite.OAuth2Provider, + auditLogger plog.AuditLogger, +) HandlerFunc { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { // Note that the login handler prevents this handler from being called with OIDC upstreams. idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(decodedState.UpstreamName) @@ -84,11 +89,13 @@ func NewPostHandler(issuerURL string, upstreamIDPs federationdomainproviders.Fed } } - session, err := downstreamsession.NewPinnipedSession(r.Context(), idp, &downstreamsession.SessionConfig{ + session, err := downstreamsession.NewPinnipedSession(r.Context(), auditLogger, &downstreamsession.SessionConfig{ UpstreamIdentity: identity, UpstreamLoginExtras: loginExtras, ClientID: authorizeRequester.GetClient().GetID(), GrantedScopes: authorizeRequester.GetGrantedScopes(), + IdentityProvider: idp, + SessionIDGetter: authorizeRequester, }) if err != nil { err = fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index cf97c7aa1..003d3f02e 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -25,6 +25,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/storage" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -1146,7 +1147,7 @@ func TestPostLoginEndpoint(t *testing.T) { rsp := httptest.NewRecorder() - subject := NewPostHandler(downstreamIssuer, tt.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper) + subject := NewPostHandler(downstreamIssuer, tt.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, plog.New()) err := subject(rsp, req, happyEncodedUpstreamState, tt.decodedState) if tt.wantErr != "" { diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 038ee01fa..83bdf5f0d 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -30,11 +30,23 @@ import ( "go.pinniped.dev/internal/psession" ) +//nolint:gochecknoglobals // please treat this as a readonly const, do not mutate +var paramsSafeToLog = sets.New[string]( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcde and refresh grants. + // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. + "grant_type", "client_id", "redirect_uri", "scope", + // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. + // Redact subject_token and actor_token. + // We don't allow all of these, but they should be safe to log. + "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", +) + func NewHandler( idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI, oauthHelper fosite.OAuth2Provider, overrideAccessTokenLifespan timeouts.OverrideLifespan, overrideIDTokenLifespan timeouts.OverrideLifespan, + auditLogger plog.AuditLogger, ) http.Handler { return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { session := psession.NewPinnipedSession() @@ -45,13 +57,17 @@ func NewHandler( return nil } + // Note that r.PostForm and accessRequest were populated by NewAccessRequest(). + auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), accessRequest, + "params", plog.SanitizeParams(r.PostForm, paramsSafeToLog)) + // Check if we are performing a refresh grant. if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) { // The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable. // The session, requested scopes, and requested audience from the original authorize request was retrieved // from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may // have already been granted on the accessRequest. - err = upstreamRefresh(r.Context(), accessRequest, idpLister) + err = upstreamRefresh(r.Context(), accessRequest, idpLister, auditLogger) if err != nil { plog.Info("upstream refresh error", oidc.FositeErrorForLog(err)...) oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err) @@ -128,6 +144,7 @@ func upstreamRefresh( ctx context.Context, accessRequest fosite.AccessRequester, idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI, + auditLogger plog.AuditLogger, ) error { session := accessRequest.GetSession().(*psession.PinnipedSession) @@ -136,6 +153,7 @@ func upstreamRefresh( return errorsx.WithStack(errMissingUpstreamSessionInternalError()) } providerName := customSessionData.ProviderName + providerType := customSessionData.ProviderType providerUID := customSessionData.ProviderUID if providerUID == "" || providerName == "" { return errorsx.WithStack(errMissingUpstreamSessionInternalError()) @@ -188,6 +206,10 @@ func upstreamRefresh( return err } + auditLogger.Audit(plog.AuditEventIdentityRefreshedFromUpstreamIDP, ctx, accessRequest, + "upstreamUsername", refreshedIdentity.UpstreamUsername, + "upstreamGroups", refreshedIdentity.UpstreamGroups) + // If the idp wants to update the session with new information from the refresh, then update it. if refreshedIdentity.IDPSpecificSessionData != nil { idp.ApplyIDPSpecificSessionDataToSession(session.Custom, refreshedIdentity.IDPSpecificSessionData) @@ -203,24 +225,37 @@ func upstreamRefresh( refreshedIdentity.UpstreamGroups = oldUntransformedGroups } - refreshedTransformedGroups, err := applyIdentityTransformationsDuringRefresh(ctx, + refreshedTransformedUsername, refreshedTransformedGroups, err := applyIdentityTransformationsDuringRefresh(ctx, idp.GetTransforms(), - oldTransformedUsername, // this function validates that the old and new transformed usernames match refreshedIdentity.UpstreamUsername, refreshedIdentity.UpstreamGroups, - session.Custom.ProviderName, - session.Custom.ProviderType, + providerName, + providerType, ) if err != nil { + auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, accessRequest, + "err", err) return err } + if oldTransformedUsername != refreshedTransformedUsername { + return errUpstreamRefreshError().WithHintf( + "Upstream refresh failed."). + WithTrace(errors.New("username in upstream refresh does not match previous value")). + WithDebugf("provider name: %q, provider type: %q", providerName, providerType) + } + if !skipGroups { warnIfGroupsChanged(ctx, oldTransformedGroups, refreshedTransformedGroups, oldTransformedUsername, accessRequest.GetClient().GetID()) // Replace the old value for the downstream groups in the user's session with the new value. session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedTransformedGroups } + auditLogger.Audit(plog.AuditEventSessionRefreshed, ctx, accessRequest, + "username", oldTransformedUsername, // not allowed to change above so must be the same as old + "groups", refreshedTransformedGroups, + "subject", previousIdentity.DownstreamSubject) + return nil } @@ -255,38 +290,30 @@ func validateSessionHasUsername(session *psession.PinnipedSession) error { } // applyIdentityTransformationsDuringRefresh is similar to downstreamsession.applyIdentityTransformations -// but with validation that the username has not changed, and with slightly different error messaging. +// but with slightly different error messaging. func applyIdentityTransformationsDuringRefresh( ctx context.Context, transforms *idtransform.TransformationPipeline, - oldTransformedUsername string, upstreamUsername string, upstreamGroups []string, providerName string, providerType psession.ProviderType, -) ([]string, error) { +) (string, []string, error) { transformationResult, err := transforms.Evaluate(ctx, upstreamUsername, upstreamGroups) if err != nil { - return nil, errUpstreamRefreshError().WithHintf( + return "", nil, errUpstreamRefreshError().WithHintf( "Upstream refresh error while applying configured identity transformations."). WithTrace(err). WithDebugf("provider name: %q, provider type: %q", providerName, providerType) } if !transformationResult.AuthenticationAllowed { - return nil, errUpstreamRefreshError().WithHintf( + return "", nil, errUpstreamRefreshError().WithHintf( "Upstream refresh rejected by configured identity policy: %s.", transformationResult.RejectedAuthenticationMessage). WithDebugf("provider name: %q, provider type: %q", providerName, providerType) } - if oldTransformedUsername != transformationResult.Username { - return nil, errUpstreamRefreshError().WithHintf( - "Upstream refresh failed."). - WithTrace(errors.New("username in upstream refresh does not match previous value")). - WithDebugf("provider name: %q, provider type: %q", providerName, providerType) - } - - return transformationResult.Groups, nil + return transformationResult.Username, transformationResult.Groups, nil } func validateAndGetDownstreamGroupsFromSession(session *psession.PinnipedSession) ([]string, error) { diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 76cfe2683..3317343e7 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -61,6 +61,7 @@ import ( "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidcclientsecretstorage" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -4916,6 +4917,7 @@ func exchangeAuthcodeForTokens( oauthHelper, timeoutsConfiguration.OverrideDefaultAccessTokenLifespan, timeoutsConfiguration.OverrideDefaultIDTokenLifespan, + plog.New(), ) authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid") diff --git a/internal/federationdomain/endpoints/tokenexchange/token_exchange.go b/internal/federationdomain/endpoints/tokenexchange/token_exchange.go index cd48157b6..d68e73def 100644 --- a/internal/federationdomain/endpoints/tokenexchange/token_exchange.go +++ b/internal/federationdomain/endpoints/tokenexchange/token_exchange.go @@ -46,17 +46,10 @@ type tokenExchangeHandler struct { var _ fosite.TokenEndpointHandler = (*tokenExchangeHandler)(nil) func (t *tokenExchangeHandler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error { + // Skip this request if it's for a different grant type. if !t.CanHandleTokenEndpointRequest(ctx, requester) { return errors.WithStack(fosite.ErrUnknownRequest) } - return nil -} - -func (t *tokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error { - // Skip this request if it's for a different grant type. - if err := t.HandleTokenEndpointRequest(ctx, requester); err != nil { - return errors.WithStack(err) - } // Validate the basic RFC8693 parameters we support. params, err := t.validateParams(requester.GetRequestForm()) @@ -64,7 +57,7 @@ func (t *tokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context return errors.WithStack(err) } - // Validate the incoming access token and lookup the information about the original authorize request. + // Validate the incoming access token and lookup the information about the original authorize request from storage. originalRequester, err := t.validateAccessToken(ctx, requester, params.subjectAccessToken) if err != nil { return errors.WithStack(err) @@ -95,8 +88,28 @@ func (t *tokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context return errors.WithStack(err) } + // Copy the original session ID from storage. + requester.SetID(originalRequester.GetID()) + // Copy the original session details from storage, which will be used by PopulateTokenEndpointResponse() to mint a token. + requester.SetSession(originalRequester.GetSession().Clone()) + // Maybe not needed, but just to be safe, copy these too, similar to how flow_refresh.go copies them. + requester.SetRequestedScopes(originalRequester.GetRequestedScopes()) + requester.SetRequestedAudience(originalRequester.GetRequestedAudience()) + + return nil +} + +func (t *tokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error { + // Skip this request if it's for a different grant type. + if !t.CanHandleTokenEndpointRequest(ctx, requester) { + return errors.WithStack(fosite.ErrUnknownRequest) + } + + // Get the requested audience parameter again, which was already validated by HandleTokenEndpointRequest() above. + requestedNewAudience := requester.GetRequestForm().Get("audience") + // Use the original authorize request information, along with the requested audience, to mint a new JWT. - responseToken, err := t.mintJWT(ctx, originalRequester, params.requestedAudience) + responseToken, err := t.mintJWT(ctx, requester, requestedNewAudience) if err != nil { return errors.WithStack(err) } @@ -108,15 +121,15 @@ func (t *tokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context return nil } -func (t *tokenExchangeHandler) mintJWT(ctx context.Context, requester fosite.Requester, audience string) (string, error) { - downscoped := fosite.NewAccessRequest(requester.GetSession()) - downscoped.Client.(*fosite.DefaultClient).ID = audience +func (t *tokenExchangeHandler) mintJWT(ctx context.Context, requester fosite.Requester, newAudience string) (string, error) { + requestWithNewAudience := fosite.NewAccessRequest(requester.GetSession()) + requestWithNewAudience.Client.(*fosite.DefaultClient).ID = newAudience // Note: if we wanted to support clients with custom token lifespans, then we would need to call // fosite.GetEffectiveLifespan() to determine the lifespan here. idTokenLifespan := t.fositeConfig.GetIDTokenLifespan(ctx) - return t.idTokenStrategy.GenerateIDToken(ctx, idTokenLifespan, downscoped) + return t.idTokenStrategy.GenerateIDToken(ctx, idTokenLifespan, requestWithNewAudience) } func (t *tokenExchangeHandler) validateSession(requester fosite.Requester) error { diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index ea5f6e016..f3104a494 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -25,6 +25,7 @@ import ( "go.pinniped.dev/internal/federationdomain/idplister" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/httputil/requestutil" "go.pinniped.dev/internal/plog" @@ -40,12 +41,13 @@ type Manager struct { mu sync.RWMutex providers []*federationdomainproviders.FederationDomainIssuer providerHandlers map[string]http.Handler // map of all routes for all providers - nextHandler http.Handler // the next handler in a chain, called when this manager didn't know how to handle a request + handlerChain http.Handler // http handlers dynamicJWKSProvider jwks.DynamicJWKSProvider // in-memory cache of per-issuer JWKS data upstreamIDPs idplister.UpstreamIdentityProvidersLister // in-memory cache of upstream IDPs secretCache *secret.Cache // in-memory cache of cryptographic material secretsClient corev1client.SecretInterface oidcClientsClient v1alpha1.OIDCClientInterface + auditLogger plog.AuditLogger } // NewManager returns an empty Manager. @@ -59,16 +61,24 @@ func NewManager( secretCache *secret.Cache, secretsClient corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface, + auditLogger plog.AuditLogger, ) *Manager { - return &Manager{ + m := &Manager{ providerHandlers: make(map[string]http.Handler), - nextHandler: nextHandler, dynamicJWKSProvider: dynamicJWKSProvider, upstreamIDPs: upstreamIDPs, secretCache: secretCache, secretsClient: secretsClient, oidcClientsClient: oidcClientsClient, + auditLogger: auditLogger, } + // nextHandler is the next handler in the chain, called when this manager didn't know how to handle a request + m.buildHandlerChain(nextHandler) + return m +} + +func (m *Manager) HandlerChain() http.Handler { + return m.handlerChain } // SetFederationDomains adds or updates all the given providerHandlers using each provider's issuer string @@ -77,7 +87,7 @@ func NewManager( // It also removes any providerHandlers that were previously added but were not passed in to // the current invocation. // -// This method assumes that all of the FederationDomainIssuer arguments have already been validated +// This method assumes that all the FederationDomainIssuer arguments have already been validated // by someone else before they are passed to this method. func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainproviders.FederationDomainIssuer) { m.mu.Lock() @@ -143,6 +153,7 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro nonce.Generate, upstreamStateEncoder, csrfCookieEncoder, + m.auditLogger, ) m.providerHandlers[(issuerHostWithPath + oidc.CallbackEndpointPath)] = callback.NewHandler( @@ -151,6 +162,7 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro upstreamStateEncoder, csrfCookieEncoder, issuerURL+oidc.CallbackEndpointPath, + m.auditLogger, ) m.providerHandlers[(issuerHostWithPath + oidc.ChooseIDPEndpointPath)] = chooseidp.NewHandler( @@ -163,38 +175,49 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro oauthHelperWithKubeStorage, timeoutsConfiguration.OverrideDefaultAccessTokenLifespan, timeoutsConfiguration.OverrideDefaultIDTokenLifespan, + m.auditLogger, ) m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler( upstreamStateEncoder, csrfCookieEncoder, login.NewGetHandler(incomingFederationDomain.IssuerPath()+oidc.PinnipedLoginPath), - login.NewPostHandler(issuerURL, idpLister, oauthHelperWithKubeStorage), + login.NewPostHandler(issuerURL, idpLister, oauthHelperWithKubeStorage, m.auditLogger), ) plog.Debug("oidc provider manager added or updated issuer", "issuer", issuerURL) } } -// ServeHTTP implements the http.Handler interface. -func (m *Manager) ServeHTTP(resp http.ResponseWriter, req *http.Request) { - requestHandler := m.findHandler(req) +func (m *Manager) buildHandlerChain(nextHandler http.Handler) { + handler := m.buildManagerHandler(nextHandler) // build the basic handler for FederationDomain endpoints + handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger) // log all requests, including audit ID + handler = requestlogger.WithAuditID(handler) // add random audit ID to request context and response headers + m.handlerChain = handler +} - // Using Info level so the user can safely configure a production Supervisor to show this message if they choose. - plog.Info("received incoming request", - "proto", req.Proto, - "method", req.Method, - "host", req.Host, - "requestSNIServerName", requestutil.SNIServerName(req), - "path", req.URL.Path, - "remoteAddr", req.RemoteAddr, - "foundFederationDomainRequestHandler", requestHandler != nil, - ) +func (m *Manager) buildManagerHandler(nextHandler http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + requestHandler := m.findHandler(req) - if requestHandler == nil { - requestHandler = m.nextHandler // couldn't find an issuer to handle the request - } - requestHandler.ServeHTTP(resp, req) + // TODO: Should this old log message change in light of the new audit logs? Or do we not want to force people to enable audit logs to debug this SNI stuff? + // Using Info level so the user can safely configure a production Supervisor to show this message if they choose. + plog.Info("received incoming request", + "proto", req.Proto, + "method", req.Method, + "host", req.Host, + "requestSNIServerName", requestutil.SNIServerName(req), + "path", req.URL.Path, + "remoteAddr", req.RemoteAddr, + "userAgent", req.UserAgent(), + "foundFederationDomainRequestHandler", requestHandler != nil, + ) + + if requestHandler == nil { + requestHandler = nextHandler // couldn't find an issuer to handle the request + } + requestHandler.ServeHTTP(resp, req) + }) } func (m *Manager) findHandler(req *http.Request) http.Handler { diff --git a/internal/federationdomain/endpointsmanager/manager_test.go b/internal/federationdomain/endpointsmanager/manager_test.go index dfde19130..d951b9767 100644 --- a/internal/federationdomain/endpointsmanager/manager_test.go +++ b/internal/federationdomain/endpointsmanager/manager_test.go @@ -26,6 +26,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/idtransform" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/secret" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -83,7 +84,7 @@ func TestManager(t *testing.T) { requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuer string) { recorder := httptest.NewRecorder() - subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix)) + subject.HandlerChain().ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix)) r.False(fallbackHandlerWasCalled) @@ -101,7 +102,7 @@ func TestManager(t *testing.T) { requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix string, expectedIDPNames []string, expectedIDPTypes string, expectedFlows []string) { recorder := httptest.NewRecorder() - subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix)) + subject.HandlerChain().ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPathV1Alpha1+requestURLSuffix)) r.False(fallbackHandlerWasCalled) @@ -145,7 +146,7 @@ func TestManager(t *testing.T) { "response_type": []string{"bat"}, } - subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.ChooseIDPEndpointPath+"?"+requiredParams.Encode())) + subject.HandlerChain().ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.ChooseIDPEndpointPath+"?"+requiredParams.Encode())) r.False(fallbackHandlerWasCalled) @@ -164,7 +165,7 @@ func TestManager(t *testing.T) { requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) { recorder := httptest.NewRecorder() - subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.AuthorizationEndpointPath+requestURLSuffix)) + subject.HandlerChain().ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.AuthorizationEndpointPath+requestURLSuffix)) r.False(fallbackHandlerWasCalled) @@ -202,7 +203,7 @@ func TestManager(t *testing.T) { Name: "__Host-pinniped-csrf", Value: csrfCookieValue, }) - subject.ServeHTTP(recorder, getRequest) + subject.HandlerChain().ServeHTTP(recorder, getRequest) r.False(fallbackHandlerWasCalled) @@ -242,7 +243,7 @@ func TestManager(t *testing.T) { "code_verifier": []string{downstreamPKCECodeVerifier}, "grant_type": []string{"authorization_code"}, }.Encode() - subject.ServeHTTP(recorder, newPostRequest(requestIssuer+oidc.TokenEndpointPath, tokenRequestBody)) + subject.HandlerChain().ServeHTTP(recorder, newPostRequest(requestIssuer+oidc.TokenEndpointPath, tokenRequestBody)) r.False(fallbackHandlerWasCalled) @@ -272,7 +273,7 @@ func TestManager(t *testing.T) { requireJWKSRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedJWKKeyID string) *jose.JSONWebKeySet { recorder := httptest.NewRecorder() - subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.JWKSEndpointPath+requestURLSuffix)) + subject.HandlerChain().ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.JWKSEndpointPath+requestURLSuffix)) r.False(fallbackHandlerWasCalled) @@ -358,13 +359,13 @@ func TestManager(t *testing.T) { cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2")) cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02")) - subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient) + subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient, plog.New()) }) when("given no providers via SetFederationDomains()", func() { it("sends all requests to the nextHandler", func() { r.False(fallbackHandlerWasCalled) - subject.ServeHTTP(httptest.NewRecorder(), newGetRequest("/anything")) + subject.HandlerChain().ServeHTTP(httptest.NewRecorder(), newGetRequest("/anything")) r.True(fallbackHandlerWasCalled) }) }) @@ -507,19 +508,19 @@ func TestManager(t *testing.T) { it("sends all non-matching host requests to the nextHandler", func() { r.False(fallbackHandlerWasCalled) wrongHostURL := strings.ReplaceAll(issuer1+oidc.WellKnownEndpointPath, "example.com", "wrong-host.com") - subject.ServeHTTP(httptest.NewRecorder(), newGetRequest(wrongHostURL)) + subject.HandlerChain().ServeHTTP(httptest.NewRecorder(), newGetRequest(wrongHostURL)) r.True(fallbackHandlerWasCalled) }) it("sends all non-matching path requests to the nextHandler", func() { r.False(fallbackHandlerWasCalled) - subject.ServeHTTP(httptest.NewRecorder(), newGetRequest("https://example.com/path-does-not-match-any-provider")) + subject.HandlerChain().ServeHTTP(httptest.NewRecorder(), newGetRequest("https://example.com/path-does-not-match-any-provider")) r.True(fallbackHandlerWasCalled) }) it("sends requests which match the issuer prefix but do not match any of that provider's known paths to the nextHandler", func() { r.False(fallbackHandlerWasCalled) - subject.ServeHTTP(httptest.NewRecorder(), newGetRequest(issuer1+"/unhandled-sub-path")) + subject.HandlerChain().ServeHTTP(httptest.NewRecorder(), newGetRequest(issuer1+"/unhandled-sub-path")) r.True(fallbackHandlerWasCalled) }) diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go new file mode 100644 index 000000000..6f9dc03ed --- /dev/null +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -0,0 +1,141 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package requestlogger + +import ( + "bufio" + "net" + "net/http" + "time" + + "github.com/google/uuid" + "k8s.io/apimachinery/pkg/types" + apisaudit "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/audit" + "k8s.io/apiserver/pkg/endpoints/responsewriter" + + "go.pinniped.dev/internal/httputil/requestutil" + "go.pinniped.dev/internal/plog" +) + +func WithAuditID(handler http.Handler) http.Handler { + return withAuditID(handler, func() string { + return uuid.New().String() + }) +} + +func withAuditID(handler http.Handler, newAuditIDFunc func() string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := audit.WithAuditContext(r.Context()) + r = r.WithContext(ctx) + + auditID := newAuditIDFunc() + audit.WithAuditID(ctx, types.UID(auditID)) + + // Send the Audit-ID response header. + w.Header().Set(apisaudit.HeaderAuditID, auditID) + + handler.ServeHTTP(w, r) + }) +} + +func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + rl := newRequestLogger(req, w, auditLogger, time.Now()) + + rl.LogRequestReceived() + defer rl.LogRequestComplete() + + statusCodeCapturingResponseWriter := responsewriter.WrapForHTTP1Or2(rl) + handler.ServeHTTP(statusCodeCapturingResponseWriter, req) + }) +} + +type requestLogger struct { + startTime time.Time + + hijacked bool + statusRecorded bool + status int + + req *http.Request + userAgent string + w http.ResponseWriter + + auditLogger plog.AuditLogger +} + +func newRequestLogger(req *http.Request, w http.ResponseWriter, auditLogger plog.AuditLogger, startTime time.Time) *requestLogger { + return &requestLogger{ + req: req, + w: w, + startTime: startTime, + userAgent: req.UserAgent(), // cache this from the req to avoid any possibility of concurrent read/write problems with headers map + auditLogger: auditLogger, + } +} + +func (rl *requestLogger) LogRequestReceived() { + r := rl.req + rl.auditLogger.Audit(plog.AuditEventHTTPRequestReceived, + r.Context(), + nil, // no session available yet in this context + "proto", r.Proto, + "method", r.Method, + "host", r.Host, + "serverName", requestutil.SNIServerName(r), + "path", r.URL.Path, + "userAgent", rl.userAgent, + "remoteAddr", r.RemoteAddr, + ) +} + +func (rl *requestLogger) LogRequestComplete() { + r := rl.req + rl.auditLogger.Audit(plog.AuditEventHTTPRequestCompleted, + r.Context(), + nil, // no session available yet in this context + "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events + "latency", time.Since(rl.startTime), + "responseStatus", rl.status, + ) +} + +// Unwrap implements responsewriter.UserProvidedDecorator. +func (rl *requestLogger) Unwrap() http.ResponseWriter { + return rl.w +} + +// Header implements http.ResponseWriter. +func (rl *requestLogger) Header() http.Header { + return rl.w.Header() +} + +// Write implements http.ResponseWriter. +func (rl *requestLogger) Write(b []byte) (int, error) { + if !rl.statusRecorded { + rl.recordStatus(http.StatusOK) // Default if WriteHeader hasn't been called + } + return rl.w.Write(b) +} + +// WriteHeader implements http.ResponseWriter. +func (rl *requestLogger) WriteHeader(status int) { + rl.recordStatus(status) + rl.w.WriteHeader(status) +} + +// Hijack implements http.Hijacker. +func (rl *requestLogger) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rl.hijacked = true + + // the outer ResponseWriter object returned by WrapForHTTP1Or2 implements + // http.Hijacker if the inner object (rl.w) implements http.Hijacker. + return rl.w.(http.Hijacker).Hijack() +} + +func (rl *requestLogger) recordStatus(status int) { + rl.status = status + rl.statusRecorded = true +} diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go new file mode 100644 index 000000000..eb4d823af --- /dev/null +++ b/internal/plog/audit_event.go @@ -0,0 +1,47 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package plog + +import ( + "net/url" + + "k8s.io/apimachinery/pkg/util/sets" +) + +type AuditEventMessage string + +const ( + AuditEventHTTPRequestReceived AuditEventMessage = "HTTP Request Received" + AuditEventHTTPRequestCompleted AuditEventMessage = "HTTP Request Completed" + AuditEventHTTPRequestParameters AuditEventMessage = "HTTP Request Parameters" + AuditEventHTTPRequestCustomHeadersUsed AuditEventMessage = "HTTP Request Custom Headers Used" + AuditEventUsingUpstreamIDP AuditEventMessage = "Using Upstream IDP" + AuditEventIdentityFromUpstreamIDP AuditEventMessage = "Identity From Upstream IDP" + AuditEventIdentityRefreshedFromUpstreamIDP AuditEventMessage = "Identity Refreshed From Upstream IDP" + AuditEventSessionStarted AuditEventMessage = "Session Started" + AuditEventSessionRefreshed AuditEventMessage = "Session Refreshed" + AuditEventAuthenticationRejectedByTransforms AuditEventMessage = "Authentication RejectedBy Transforms" + AuditEventUpstreamOIDCTokenRevoked AuditEventMessage = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential + AuditEventSessionGarbageCollected AuditEventMessage = "Session Garbage Collected" + AuditEventTokenCredentialRequest AuditEventMessage = "TokenCredentialRequest" //nolint:gosec // this is not a credential +) + +// SanitizeParams can be used to redact all params not included in the allowedKeys set. +// Useful when audit logging AuditEventHTTPRequestParameters events. +func SanitizeParams(params url.Values, allowedKeys sets.Set[string]) string { + if len(params) == 0 { + return "" + } + sanitized := url.Values{} + for key := range params { + if allowedKeys.Has(key) { + sanitized[key] = params[key] + } else { + for range params[key] { + sanitized.Add(key, "redacted") + } + } + } + return sanitized.Encode() +} diff --git a/internal/plog/audit_event_test.go b/internal/plog/audit_event_test.go new file mode 100644 index 000000000..9cb031b6a --- /dev/null +++ b/internal/plog/audit_event_test.go @@ -0,0 +1,75 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package plog + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestSanitizeParams(t *testing.T) { + tests := []struct { + name string + params url.Values + allowedKeys sets.Set[string] + want string + }{ + { + name: "nil values", + params: nil, + allowedKeys: nil, + want: "", + }, + { + name: "empty values", + params: url.Values{}, + allowedKeys: nil, + want: "", + }, + { + name: "all allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New("foo", "bar"), + want: "bar=d&bar=e&bar=f&foo=a&foo=b&foo=c", + }, + { + name: "all allowed values with single values", + params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, + allowedKeys: sets.New("foo", "bar"), + want: "bar=d&foo=a", + }, + { + name: "some allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New("foo"), + want: "bar=redacted&bar=redacted&bar=redacted&foo=a&foo=b&foo=c", + }, + { + name: "some allowed values with single values", + params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, + allowedKeys: sets.New("foo"), + want: "bar=redacted&foo=a", + }, + { + name: "no allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New[string](), + want: "bar=redacted&bar=redacted&bar=redacted&foo=redacted&foo=redacted&foo=redacted", + }, + { + name: "nil allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: nil, + want: "bar=redacted&bar=redacted&bar=redacted&foo=redacted&foo=redacted&foo=redacted", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, SanitizeParams(tt.params, tt.allowedKeys)) + }) + } +} diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 1f989c25c..894b0acc7 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -28,19 +28,37 @@ package plog import ( + "context" "os" "slices" "github.com/go-logr/logr" + "k8s.io/apiserver/pkg/audit" ) const errorKey = "error" // this matches zapr's default for .Error calls (which is asserted via tests) +type SessionIDGetter interface { + GetID() string +} + +// AuditLogger is only the audit logging part of Logger. There is no global function for Audit because +// that would make unit testing of audit logs harder. +type AuditLogger interface { + // Audit writes an audit event to the log. + // reqCtx and session may be null. + // When possible, pass the http request's context as reqCtx, so we may read the audit ID from the context. + // When possible, pass the fosite.Requester or fosite.Request as the session, so we can log the session ID. + Audit(msg AuditEventMessage, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) +} + // Logger implements the plog logging convention described above. The global functions in this package // such as Info should be used when one does not intend to write tests assertions for specific log messages. // If test assertions are desired, Logger should be passed in as an input. New should be used as the // production implementation and TestLogger should be used to write test assertions. type Logger interface { + AuditLogger + Error(msg string, err error, keysAndValues ...any) Warning(msg string, keysAndValues ...any) WarningErr(msg string, err error, keysAndValues ...any) @@ -79,10 +97,47 @@ func New() Logger { return pLogger{} } +// Error logs show in the pod log output as `"level":"error","message":"some error msg"` +// where the message text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Error logs cannot be suppressed by the global log level configuration. func (p pLogger) Error(msg string, err error, keysAndValues ...any) { p.logr().WithCallDepth(p.depth+1).Error(err, msg, keysAndValues...) } +// Audit logs show in the pod log output as `"level":"info","message":"some msg","auditEvent":true` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Audit logs cannot be suppressed by the global log level configuration, but rather can be disabled +// by their own separate configuration. This is because Audit logs should always be printed when they are desired +// by the admin, regardless of global log level, yet the admin should also have a way to entirely disable them +// when they want to avoid potential PII (e.g. usernames) in their pod logs. +// TODO: Add a way to disable output of audit logs, separate from the log level config. +func (p pLogger) Audit(msg AuditEventMessage, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { + // Always add a key/value auditEvent=true. + keysAndValues = slices.Concat([]any{"auditEvent", true}, keysAndValues) + + var auditID string + if reqCtx != nil { + auditID = audit.GetAuditIDTruncated(reqCtx) + } + if len(auditID) > 0 { + keysAndValues = slices.Concat([]any{"auditID", auditID}, keysAndValues) + } + + var sessionID string + if session != nil { + sessionID = session.GetID() + } + if len(sessionID) > 0 { + keysAndValues = slices.Concat([]any{"sessionID", sessionID}, keysAndValues) + } + + p.logr().V(klogLevelWarning).WithCallDepth(p.depth+1).Info(string(msg), keysAndValues...) +} + func (p pLogger) warningDepth(msg string, depth int, keysAndValues ...any) { if p.logr().V(klogLevelWarning).Enabled() { // klog's structured logging has no concept of a warning (i.e. no WarningS function) @@ -94,10 +149,20 @@ func (p pLogger) warningDepth(msg string, depth int, keysAndValues ...any) { } } +// Warning logs show in the pod log output as `"level":"info","message":"some msg","warning":true` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Warning logs cannot be suppressed by the global log level configuration. func (p pLogger) Warning(msg string, keysAndValues ...any) { p.warningDepth(msg, p.depth+1, keysAndValues...) } +// WarningErr logs show in the pod log output as `"level":"info","message":"some msg","warning":true,"error":"some error msg"` +// where the message text comes from the msg parameter and the error text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// WarningErr logs cannot be suppressed by the global log level configuration. func (p pLogger) WarningErr(msg string, err error, keysAndValues ...any) { p.warningDepth(msg, p.depth+1, slices.Concat([]any{errorKey, err}, keysAndValues)...) } @@ -108,10 +173,20 @@ func (p pLogger) infoDepth(msg string, depth int, keysAndValues ...any) { } } +// Info logs show in the pod log output as `"level":"info","message":"some msg"` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Info logs are suppressed by the global log level configuration, unless it is set to "info" or above. func (p pLogger) Info(msg string, keysAndValues ...any) { p.infoDepth(msg, p.depth+1, keysAndValues...) } +// InfoErr logs show in the pod log output as `"level":"info","message":"some msg","error":"some error msg"` +// where the message text comes from the msg parameter and the error text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// InfoErr logs are suppressed by the global log level configuration, unless it is set to "info" or above. func (p pLogger) InfoErr(msg string, err error, keysAndValues ...any) { p.infoDepth(msg, p.depth+1, slices.Concat([]any{errorKey, err}, keysAndValues)...) } @@ -122,10 +197,20 @@ func (p pLogger) debugDepth(msg string, depth int, keysAndValues ...any) { } } +// Debug logs show in the pod log output as `"level":"debug","message":"some msg"` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Debug logs are suppressed by the global log level configuration, unless it is set to "debug" or above. func (p pLogger) Debug(msg string, keysAndValues ...any) { p.debugDepth(msg, p.depth+1, keysAndValues...) } +// DebugErr logs show in the pod log output as `"level":"debug","message":"some msg","error":"some error msg"` +// where the message text comes from the msg parameter and the error text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// DebugErr logs are suppressed by the global log level configuration, unless it is set to "debug" or above. func (p pLogger) DebugErr(msg string, err error, keysAndValues ...any) { p.debugDepth(msg, p.depth+1, slices.Concat([]any{errorKey, err}, keysAndValues)...) } @@ -136,20 +221,39 @@ func (p pLogger) traceDepth(msg string, depth int, keysAndValues ...any) { } } +// Trace logs show in the pod log output as `"level":"trace","message":"some msg"` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Trace logs are suppressed by the global log level configuration, unless it is set to "trace" or above. func (p pLogger) Trace(msg string, keysAndValues ...any) { p.traceDepth(msg, p.depth+1, keysAndValues...) } +// TraceErr logs show in the pod log output as `"level":"trace","message":"some msg","error":"some error msg"` +// where the message text comes from the msg parameter and the error text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// TraceErr logs are suppressed by the global log level configuration, unless it is set to "trace" or above. func (p pLogger) TraceErr(msg string, err error, keysAndValues ...any) { p.traceDepth(msg, p.depth+1, slices.Concat([]any{errorKey, err}, keysAndValues)...) } +// All logs show in the pod log output as `"level":"all","message":"some msg"` +// where the message text comes from the msg parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// All logs are suppressed by the global log level configuration, unless it is set to "all" or above. func (p pLogger) All(msg string, keysAndValues ...any) { if p.logr().V(klogLevelAll).Enabled() { p.logr().V(klogLevelAll).WithCallDepth(p.depth+1).Info(msg, keysAndValues...) } } +// Always logs show in the pod log output exactly the same as an Info() message, +// except Always logs are always logged regardless of log level configuration. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Always logs cannot be suppressed by the global log level configuration. func (p pLogger) Always(msg string, keysAndValues ...any) { p.logr().WithCallDepth(p.depth+1).Info(msg, keysAndValues...) } diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 477cdde68..447a5500c 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -22,6 +22,7 @@ import ( loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/clientcertissuer" + "go.pinniped.dev/internal/plog" ) // clientCertificateTTL is the TTL for short-lived client certificates returned by this API. @@ -31,11 +32,17 @@ type TokenCredentialRequestAuthenticator interface { AuthenticateTokenCredentialRequest(ctx context.Context, req *loginapi.TokenCredentialRequest) (user.Info, error) } -func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer clientcertissuer.ClientCertIssuer, resource schema.GroupResource) *REST { +func NewREST( + authenticator TokenCredentialRequestAuthenticator, + issuer clientcertissuer.ClientCertIssuer, + resource schema.GroupResource, + auditLogger plog.AuditLogger, +) *REST { return &REST{ authenticator: authenticator, issuer: issuer, tableConvertor: rest.NewDefaultTableConvertor(resource), + auditLogger: auditLogger, } } @@ -43,6 +50,7 @@ type REST struct { authenticator TokenCredentialRequestAuthenticator issuer clientcertissuer.ClientCertIssuer tableConvertor rest.TableConvertor + auditLogger plog.AuditLogger } // Assert that our *REST implements all the optional interfaces that we expect it to implement. @@ -123,6 +131,12 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation traceSuccess(t, userInfo, true) + r.auditLogger.Audit(plog.AuditEventTokenCredentialRequest, ctx, nil, + "username", userInfo.GetName(), + "groups", userInfo.GetGroups(), + "authenticated", true, + "expires", expires.Format(time.RFC3339)) + return &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ Credential: &loginapi.ClusterCredential{ diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 5054c5cc9..20f8b6c41 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -28,11 +28,12 @@ import ( "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/mocks/mockcredentialrequest" "go.pinniped.dev/internal/mocks/mockissuer" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" ) func TestNew(t *testing.T) { - r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}) + r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, plog.New()) require.NotNil(t, r) require.False(t, r.NamespaceScoped()) require.Equal(t, []string{"pinniped"}, r.Categories()) @@ -103,7 +104,7 @@ func TestCreate(t *testing.T) { 5*time.Minute, ).Return([]byte("test-cert"), []byte("test-key"), nil) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -142,7 +143,7 @@ func TestCreate(t *testing.T) { IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil, fmt.Errorf("some certificate authority error")) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) @@ -155,7 +156,7 @@ func TestCreate(t *testing.T) { requestAuthenticator := mockcredentialrequest.NewMockTokenCredentialRequestAuthenticator(ctrl) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -170,7 +171,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(nil, errors.New("some webhook error")) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -185,7 +186,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(&user.DefaultInfo{Name: ""}, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -204,7 +205,7 @@ func TestCreate(t *testing.T) { Groups: []string{"test-group-1", "test-group-2"}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -223,7 +224,7 @@ func TestCreate(t *testing.T) { Extra: map[string][]string{"test-key": {"test-val-1", "test-val-2"}}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, req) @@ -233,7 +234,7 @@ func TestCreate(t *testing.T) { it("CreateFailsWhenGivenTheWrongInputType", func() { notACredentialRequest := runtime.Unknown{} - response, err := NewREST(nil, nil, schema.GroupResource{}).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( genericapirequest.NewContext(), ¬ACredentialRequest, rest.ValidateAllObjectFunc, @@ -244,7 +245,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { - storage := NewREST(nil, nil, schema.GroupResource{}) + storage := NewREST(nil, nil, schema.GroupResource{}, plog.New()) response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ Token: "", })) @@ -255,7 +256,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenValidationFails", func() { - storage := NewREST(nil, nil, schema.GroupResource{}) + storage := NewREST(nil, nil, schema.GroupResource{}, plog.New()) response, err := storage.Create( context.Background(), validCredentialRequest(), @@ -275,7 +276,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, plog.New()) response, err := storage.Create( context.Background(), req, @@ -296,7 +297,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, plog.New()) validationFunctionWasCalled := false var validationFunctionSawTokenValue string response, err := storage.Create( @@ -316,7 +317,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( genericapirequest.NewContext(), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -330,7 +331,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenNamespaceIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), validCredentialRequest(), rest.ValidateAllObjectFunc, diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index 83c28664a..8fc9b9d58 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -167,6 +167,7 @@ func prepareControllers( kubeClient, secretInformer, controllerlib.WithInformer, + plog.New(), ), singletonWorker, ). @@ -483,6 +484,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis &secretCache, clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders client.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), + plog.New(), ) // Get the "real" name of the client secret supervisor API group (i.e., the API group name with the @@ -544,7 +546,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis } defer func() { _ = httpListener.Close() }() - startServer(ctx, shutdown, httpListener, oidProvidersManager) + startServer(ctx, shutdown, httpListener, oidProvidersManager.HandlerChain()) plog.Debug("supervisor http listener started", "address", httpListener.Addr().String()) } @@ -601,7 +603,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis } defer func() { _ = httpsListener.Close() }() - startServer(ctx, shutdown, httpsListener, oidProvidersManager) + startServer(ctx, shutdown, httpsListener, oidProvidersManager.HandlerChain()) plog.Debug("supervisor https listener started", "address", httpsListener.Addr().String()) } From b20e890f15537e06afd3c15f971d430dc82838e3 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 29 Oct 2024 16:47:29 -0500 Subject: [PATCH 02/71] Add testutil.RequireLogLines to verify multiple log lines at once --- cmd/pinniped/cmd/kubeconfig_test.go | 9 ++------ .../github_upstream_watcher_test.go | 7 +----- internal/crypto/ptls/log_profiles_test.go | 8 ++----- internal/testutil/log_lines.go | 22 +++++++++++++++++++ 4 files changed, 27 insertions(+), 19 deletions(-) create mode 100644 internal/testutil/log_lines.go diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go index 26a5e6077..1a99584e1 100644 --- a/cmd/pinniped/cmd/kubeconfig_test.go +++ b/cmd/pinniped/cmd/kubeconfig_test.go @@ -11,7 +11,6 @@ import ( "os" "path/filepath" "slices" - "strings" "testing" "time" @@ -3293,14 +3292,10 @@ func TestGetKubeconfig(t *testing.T) { require.NoError(t, err) } - var expectedLogs string if tt.wantLogs != nil { - temp := tt.wantLogs(string(testServerCA), testServer.URL) - if len(temp) > 0 { - expectedLogs = strings.Join(tt.wantLogs(string(testServerCA), testServer.URL), "\n") + "\n" - } + wantLogs := tt.wantLogs(string(testServerCA), testServer.URL) + testutil.RequireLogLines(t, wantLogs, &log) } - require.Equal(t, expectedLogs, log.String()) expectedStdout := "" if tt.wantStdout != nil { diff --git a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go index 4447e82b6..7f734d7ea 100644 --- a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go @@ -2555,12 +2555,7 @@ func TestController(t *testing.T) { require.Len(t, actualIDP.Status.Conditions, countExpectedConditions) require.Equal(t, tt.wantResultingUpstreams[i], *actualIDP) } - - expectedLogs := "" - if len(tt.wantLogs) > 0 { - expectedLogs = strings.Join(tt.wantLogs, "\n") + "\n" - } - require.Equal(t, expectedLogs, log.String()) + testutil.RequireLogLines(t, tt.wantLogs, &log) // This needs to happen after the expected condition LastTransitionTime has been updated. wantActions := make([]coretesting.Action, 3+len(tt.wantResultingUpstreams)) diff --git a/internal/crypto/ptls/log_profiles_test.go b/internal/crypto/ptls/log_profiles_test.go index 0b27d09b4..9f5c39153 100644 --- a/internal/crypto/ptls/log_profiles_test.go +++ b/internal/crypto/ptls/log_profiles_test.go @@ -4,12 +4,10 @@ package ptls import ( - "strings" "testing" - "github.com/stretchr/testify/require" - "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/testutil" ) func TestLogAllProfiles(t *testing.T) { @@ -22,7 +20,5 @@ func TestLogAllProfiles(t *testing.T) { `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"ptls/log_profiles.go:$ptls.logProfile","message":"tls configuration","profile name":"DefaultLDAP","MinVersion":"TLS 1.2","MaxVersion":"NONE","CipherSuites":["TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA","TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA","TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA"],"NextProtos":["h2","http/1.1"]}`, `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"ptls/log_profiles.go:$ptls.logProfile","message":"tls configuration","profile name":"Secure","MinVersion":"TLS 1.3","MaxVersion":"NONE","CipherSuites":[],"NextProtos":["h2","http/1.1"]}`, } - expectedOutput := strings.Join(expectedLines, "\n") + "\n" - - require.Equal(t, expectedOutput, log.String()) + testutil.RequireLogLines(t, expectedLines, log) } diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go new file mode 100644 index 000000000..3b9e321a4 --- /dev/null +++ b/internal/testutil/log_lines.go @@ -0,0 +1,22 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func RequireLogLines(t *testing.T, wantLogs []string, log *bytes.Buffer) { + t.Helper() + + expectedLogs := "" + if len(wantLogs) > 0 { + expectedLogs = strings.Join(wantLogs, "\n") + "\n" + } + require.Equal(t, expectedLogs, log.String()) +} From fd5a10bee737f5f9f897e0e92ab3ddfa03acf8c7 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 29 Oct 2024 16:52:19 -0500 Subject: [PATCH 03/71] WIP: Add audit event when upstream redirect occurs and backfill tests --- .../endpoints/auth/auth_handler.go | 18 ++- .../endpoints/auth/auth_handler_test.go | 113 ++++++++++++++---- internal/plog/audit_event.go | 1 + 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index a0e75bb46..b950eb73b 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -5,6 +5,8 @@ package auth import ( + "crypto/sha256" + "encoding/hex" "fmt" "net/http" "net/url" @@ -210,7 +212,11 @@ func (h *authorizeHandler) authorize( if requestedBrowserlessFlow { err = h.authorizeWithoutBrowser(r, w, oauthHelper, authorizeRequester, idp) } else { - err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) + var authorizeID string + authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) + + h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), nil, + "authorizeID", authorizeID) } if err != nil { oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow) @@ -261,7 +267,7 @@ func (h *authorizeHandler) authorizeWithBrowser( oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, idp resolvedprovider.FederationDomainResolvedIdentityProvider, -) error { +) (string, error) { authRequestState, err := generateUpstreamAuthorizeRequestState(r, w, authorizeRequester, oauthHelper, @@ -274,19 +280,21 @@ func (h *authorizeHandler) authorizeWithBrowser( h.upstreamStateEncoder, ) if err != nil { - return err + return "", err } redirectURL, err := idp.UpstreamAuthorizeRedirectURL(authRequestState, h.downstreamIssuerURL) if err != nil { - return err + return "", err } http.Redirect(w, r, redirectURL, http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 ) - return nil + upstreamStateHash := sha256.Sum256([]byte(authRequestState.EncodedStateParam)) + authorizeID := hex.EncodeToString(upstreamStateHash[:]) + return authorizeID, nil } func shouldShowIDPChooser( diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 5bffb75dd..515c6d64e 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -4,7 +4,10 @@ package auth import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "html" @@ -657,6 +660,11 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) + generateAuthorizeId := func(encodedStateParam string) string { + upstreamStateHash := sha256.Sum256([]byte(encodedStateParam)) + return hex.EncodeToString(upstreamStateHash[:]) + } + type testCase struct { name string @@ -684,6 +692,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref bool wantLocationHeader string wantUpstreamStateParamInLocationHeader bool + wantAuditLogs func(encodedStateParam string) []string // Assertions for when an authcode should be returned, i.e. the request was authenticated by an // upstream LDAP provider or an upstream OIDC password grant flow. @@ -720,6 +729,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + } + }, }, { name: "OIDC upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", @@ -738,6 +755,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + } + }, }, { name: "GitHub upstream browser flow happy path using GET without a CSRF cookie", @@ -755,6 +780,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(nil, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + } + }, }, { name: "GitHub upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", @@ -773,6 +806,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + } + }, }, { name: "LDAP upstream browser flow happy path using GET without a CSRF cookie", @@ -1065,6 +1106,13 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + } + }, }, { name: "LDAP cli upstream happy path using GET with identity transformations which change username and groups", @@ -1092,6 +1140,13 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo happyLDAPUsernameFromAuthenticator, happyLDAPGroups, ), + wantAuditLogs: func(encodedStateParam string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + } + }, }, { name: "LDAP cli upstream with identity transformations which reject auth", @@ -3479,7 +3534,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo }, } - runOneTestCase := func(t *testing.T, test testCase, subject http.Handler, kubeOauthStore *storage.KubeStorage, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset, secretsClient v1.SecretInterface) { + runOneTestCase := func( + t *testing.T, + test testCase, + subject http.Handler, + kubeOauthStore *storage.KubeStorage, + supervisorClient *supervisorfake.Clientset, + kubeClient *fake.Clientset, + secretsClient v1.SecretInterface, + auditLog *bytes.Buffer, + ) { if test.kubeResources != nil { test.kubeResources(t, supervisorClient, kubeClient) } @@ -3520,12 +3584,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo switch { case test.wantLocationHeader != "": if test.wantUpstreamStateParamInLocationHeader { - requireEqualDecodedStateParams(t, actualLocation, test.wantLocationHeader, test.stateEncoder) + actualQueryStateParam := requireEqualDecodedStateParams(t, actualLocation, test.wantLocationHeader, test.stateEncoder) + // Ignore the state, since it was encoded with a randomly-generated initialization vector that cannot be reproduced. + requireEqualURLsIgnoringState(t, actualLocation, test.wantLocationHeader) + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualQueryStateParam) + testutil.RequireLogLines(t, wantAuditLogs, auditLog) + } + } else { + require.Equal(t, test.wantLocationHeader, actualLocation) } - // The upstream state param is encoded using a timestamp at the beginning so we don't want to - // compare those states since they may be different, but we do want to compare the downstream - // state param that should be exactly the same. - requireEqualURLs(t, actualLocation, test.wantLocationHeader, test.wantUpstreamStateParamInLocationHeader) // Authorization requests for either a successful OIDC upstream or for an error with any upstream // should never use Kube storage. There is only one exception to this rule, which is that certain @@ -3618,21 +3686,24 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if len(test.wantDownstreamAdditionalClaims) > 0 { require.True(t, oidcIDPsCount > 0, "wantDownstreamAdditionalClaims requires at least one OIDC IDP") } - + var auditLog bytes.Buffer + auditLogger := plog.TestLogger(t, &auditLog) subject := NewHandler( downstreamIssuer, idps, oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, - plog.New(), + auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) }) } t.Run("allows upstream provider configuration to change between requests", func(t *testing.T) { test := tests[0] + // TODO: check to see if it's easy to verify audit logs + test.wantAuditLogs = nil // Double-check that we are re-using the happy path test case here as we intend. require.Equal(t, "OIDC upstream browser flow happy path using GET without a CSRF cookie", test.name) @@ -3643,16 +3714,18 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient) oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient) idpLister := test.idps.BuildFederationDomainIdentityProvidersListerFinder() + var auditLog bytes.Buffer + auditLogger := plog.TestLogger(t, &auditLog) subject := NewHandler( downstreamIssuer, idpLister, oauthHelperWithNullStorage, oauthHelperWithRealStorage, test.generateCSRF, test.generatePKCE, test.generateNonce, test.stateEncoder, test.cookieEncoder, - plog.New(), + auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) // Call the idpLister's setter to change the upstream IDP settings. newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). @@ -3695,7 +3768,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo // modified expectations. This should ensure that the implementation is using the in-memory cache // of upstream IDP settings appropriately in terms of always getting the values from the cache // on every request. - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) }) } @@ -3712,7 +3785,7 @@ type expectedPasswordGrant struct { args *oidctestutil.PasswordCredentialsGrantAndValidateTokensArgs } -func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder oidc.Codec) { +func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL string, stateParamDecoder oidc.Codec) string { t.Helper() actualLocationURL, err := url.Parse(actualURL) require.NoError(t, err) @@ -3732,9 +3805,11 @@ func requireEqualDecodedStateParams(t *testing.T, actualURL string, expectedURL require.NoError(t, err) require.Equal(t, expectedDecodedStateParam, actualDecodedStateParam) + + return actualQueryStateParam } -func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignoreState bool) { +func requireEqualURLsIgnoringState(t *testing.T, actualURL string, expectedURL string) { t.Helper() actualLocationURL, err := url.Parse(actualURL) require.NoError(t, err) @@ -3757,11 +3832,9 @@ func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignore expectedLocationQuery := expectedLocationURL.Query() actualLocationQuery := actualLocationURL.Query() - // Let the caller ignore the state, since it may contain a digest at the end that is difficult to - // predict because it depends on a time.Now() timestamp. - if ignoreState { - expectedLocationQuery.Del("state") - actualLocationQuery.Del("state") - } + // Ignore the state, since it was encoded with a randomly-generated initialization vector that cannot be reproduced. + expectedLocationQuery.Del("state") + actualLocationQuery.Del("state") + require.Equal(t, expectedLocationQuery, actualLocationQuery) } diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go index eb4d823af..727e031c3 100644 --- a/internal/plog/audit_event.go +++ b/internal/plog/audit_event.go @@ -25,6 +25,7 @@ const ( AuditEventUpstreamOIDCTokenRevoked AuditEventMessage = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential AuditEventSessionGarbageCollected AuditEventMessage = "Session Garbage Collected" AuditEventTokenCredentialRequest AuditEventMessage = "TokenCredentialRequest" //nolint:gosec // this is not a credential + AuditEventUpstreamAuthorizeRedirect AuditEventMessage = "Upstream Authorize Redirect" ) // SanitizeParams can be used to redact all params not included in the allowedKeys set. From aee56c388f03b3de25461a2c85ef5768b0c82be4 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 30 Oct 2024 15:22:13 -0500 Subject: [PATCH 04/71] Check the sessionID as well Co-authored-by: Ryan Richard --- .../downstreamsession/downstream_session.go | 8 +- .../endpoints/auth/auth_handler_test.go | 112 ++++++++++++------ .../callback/callback_handler_test.go | 4 +- .../login/post_login_handler_test.go | 4 +- .../endpointsmanager/manager.go | 12 +- .../requestlogger/request_logger.go | 21 ++-- internal/plog/audit_event.go | 2 +- .../session_storage_assertions.go | 12 +- 8 files changed, 113 insertions(+), 62 deletions(-) diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 5097a6adf..96d7cce11 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -49,14 +49,18 @@ func NewPinnipedSession( ) (*psession.PinnipedSession, error) { now := time.Now().UTC() - auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, c.SessionIDGetter, + // Do not associate this audit event with a session ID, since the session has not yet "started", + // and this session may not be persisted to permanent storage. + auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, nil, "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, "upstreamGroups", c.UpstreamIdentity.UpstreamGroups) downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { - auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, c.SessionIDGetter, + // Do not associate this audit event with a session ID, since we reject this session (and + // will never write it to permanent storage). + auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, nil, "err", err) return nil, err } diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 515c6d64e..651ac22d7 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -37,6 +37,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" @@ -692,7 +693,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref bool wantLocationHeader string wantUpstreamStateParamInLocationHeader bool - wantAuditLogs func(encodedStateParam string) []string + wantAuditLogs func(encodedStateParam, sessionID string) []string // Assertions for when an authcode should be returned, i.e. the request was authenticated by an // upstream LDAP provider or an upstream OIDC password grant flow. @@ -729,12 +730,12 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, } }, }, @@ -755,12 +756,12 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, } }, }, @@ -780,12 +781,12 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(nil, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, } }, }, @@ -806,12 +807,12 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, } }, }, @@ -981,6 +982,15 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, + wantAuditLogs: func(encodedStateParam, sessionID string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-password-granting-oidc-idp","resourceName":"some-password-granting-oidc-idp","resourceUID":"some-password-granting-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"test-oidc-pinniped-username","upstreamGroups":["test-pinniped-group-0","test-pinniped-group-1"]}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"test-oidc-pinniped-username","groups":["test-pinniped-group-0","test-pinniped-group-1"],"subject":"https://my-upstream-issuer.com?idpName=some-password-granting-oidc-idp&sub=abc123-some+guid","additionalClaims":{},"warnings":[]}`, + } + }, }, { name: "OIDC upstream password grant happy path using GET with identity transformations which change username and groups", @@ -1023,6 +1033,15 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery), wantBodyString: "", + wantAuditLogs: func(encodedStateParam, sessionID string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-password-granting-oidc-idp","resourceName":"some-password-granting-oidc-idp","resourceUID":"some-password-granting-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"test-oidc-pinniped-username","upstreamGroups":["test-pinniped-group-0","test-pinniped-group-1"]}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Authentication Rejected By Transforms","auditID":"some-audit-id","auditEvent":true,"err":"configured identity policy rejected this authentication: authentication was rejected by a configured policy"}`, + } + }, }, { name: "OIDC upstream password grant happy path using GET with additional claim mappings", @@ -1087,7 +1106,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamAdditionalClaims: nil, // downstream claims are empty }, { - name: "LDAP cli upstream happy path using GET", + name: "LDAP upstream cli_password flow happy path using GET", idps: testidplister.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, path: happyGetRequestPathForLDAPUpstream, @@ -1106,11 +1125,13 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"some-mapped-ldap-username","upstreamGroups":["group1","group2","group3"]}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"some-mapped-ldap-username","groups":["group1","group2","group3"],"subject":"ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid","additionalClaims":null,"warnings":[]}`, } }, }, @@ -1140,11 +1161,13 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo happyLDAPUsernameFromAuthenticator, happyLDAPGroups, ), - wantAuditLogs: func(encodedStateParam string) []string { + wantAuditLogs: func(encodedStateParam, sessionID string) []string { return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"some-mapped-ldap-username","upstreamGroups":["group1","group2","group3"]}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"username_prefix:some-mapped-ldap-username","groups":["groups_prefix:group1","groups_prefix:group2","groups_prefix:group3"],"subject":"ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid","additionalClaims":null,"warnings":[]}`, } }, }, @@ -1486,6 +1509,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), wantBodyString: "", + wantAuditLogs: func(encodedStateParam, sessionID string) []string { + return []string{ + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, + `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":""}`, + } + }, }, { name: "OIDC upstream browser flow with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", @@ -3561,6 +3592,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo req.Header.Set("Pinniped-Password", *test.customPasswordHeader) } rsp := httptest.NewRecorder() + + req, _ = requestlogger.NewRequestWithAuditID(req, func() string { + return "some-audit-id" + }) subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) @@ -3572,7 +3607,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) if test.wantPasswordGrantCall != nil { - test.wantPasswordGrantCall.args.Ctx = reqContext + test.wantPasswordGrantCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToPasswordCredentialsGrantAndValidateTokens(t, test.wantPasswordGrantCall.performedByUpstreamName, test.wantPasswordGrantCall.args, ) @@ -3581,16 +3616,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo } actualLocation := rsp.Header().Get("Location") + actualQueryStateParam := "" + sessionID := "" switch { case test.wantLocationHeader != "": if test.wantUpstreamStateParamInLocationHeader { - actualQueryStateParam := requireEqualDecodedStateParams(t, actualLocation, test.wantLocationHeader, test.stateEncoder) + actualQueryStateParam = requireEqualDecodedStateParams(t, actualLocation, test.wantLocationHeader, test.stateEncoder) // Ignore the state, since it was encoded with a randomly-generated initialization vector that cannot be reproduced. requireEqualURLsIgnoringState(t, actualLocation, test.wantLocationHeader) - if test.wantAuditLogs != nil { - wantAuditLogs := test.wantAuditLogs(actualQueryStateParam) - testutil.RequireLogLines(t, wantAuditLogs, auditLog) - } } else { require.Equal(t, test.wantLocationHeader, actualLocation) } @@ -3606,7 +3639,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo test.wantDownstreamClientID = pinnipedCLIClientID // default assertion value when not provided by test case } require.Len(t, rsp.Header().Values("Location"), 1) - oidctestutil.RequireAuthCodeRegexpMatch( + sessionID = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Header().Get("Location"), test.wantRedirectLocationRegexp, @@ -3630,6 +3663,11 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo require.Empty(t, rsp.Header().Values("Location")) } + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualQueryStateParam, sessionID) + testutil.RequireLogLines(t, wantAuditLogs, auditLog) + } + switch { case test.wantBodyJSON != "": require.JSONEq(t, test.wantBodyJSON, rsp.Body.String()) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 12e6d65e9..366929aaf 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -1806,7 +1806,7 @@ func TestCallbackEndpoint(t *testing.T) { // Else if we want a body that contains a regex-matched auth code, assert that (for "response_mode=form_post"). case test.wantBodyFormResponseRegexp != "": - oidctestutil.RequireAuthCodeRegexpMatch( + _ = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Body.String(), test.wantBodyFormResponseRegexp, @@ -1834,7 +1834,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.wantRedirectLocationRegexp != "" { require.Len(t, rsp.Header().Values("Location"), 1) - oidctestutil.RequireAuthCodeRegexpMatch( + _ = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Header().Get("Location"), test.wantRedirectLocationRegexp, diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 003d3f02e..24591b9a5 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -1168,7 +1168,7 @@ func TestPostLoginEndpoint(t *testing.T) { // Expecting a success redirect to the client. require.Equal(t, tt.wantBodyString, rsp.Body.String()) require.Len(t, rsp.Header().Values("Location"), 1) - oidctestutil.RequireAuthCodeRegexpMatch( + _ = oidctestutil.RequireAuthCodeRegexpMatch( t, actualLocation, tt.wantRedirectLocationRegexp, @@ -1204,7 +1204,7 @@ func TestPostLoginEndpoint(t *testing.T) { // Expecting the body of the response to be a html page with a form (for "response_mode=form_post"). _, hasLocationHeader := rsp.Header()["Location"] require.False(t, hasLocationHeader) - oidctestutil.RequireAuthCodeRegexpMatch( + _ = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Body.String(), tt.wantBodyFormResponseRegexp, diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index f3104a494..0512d1b3e 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "github.com/google/uuid" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" @@ -190,9 +191,14 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro } func (m *Manager) buildHandlerChain(nextHandler http.Handler) { - handler := m.buildManagerHandler(nextHandler) // build the basic handler for FederationDomain endpoints - handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger) // log all requests, including audit ID - handler = requestlogger.WithAuditID(handler) // add random audit ID to request context and response headers + // build the basic handler for FederationDomain endpoints + handler := m.buildManagerHandler(nextHandler) + // log all requests, including audit ID + handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger) + // add random audit ID to request context and response headers + handler = requestlogger.WithAuditID(handler, func() string { + return uuid.New().String() + }) m.handlerChain = handler } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 6f9dc03ed..7657cebbd 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -9,7 +9,6 @@ import ( "net/http" "time" - "github.com/google/uuid" "k8s.io/apimachinery/pkg/types" apisaudit "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit" @@ -19,19 +18,19 @@ import ( "go.pinniped.dev/internal/plog" ) -func WithAuditID(handler http.Handler) http.Handler { - return withAuditID(handler, func() string { - return uuid.New().String() - }) +func NewRequestWithAuditID(r *http.Request, newAuditIDFunc func() string) (*http.Request, string) { + ctx := audit.WithAuditContext(r.Context()) + r = r.WithContext(ctx) + + auditID := newAuditIDFunc() + audit.WithAuditID(ctx, types.UID(auditID)) + + return r, auditID } -func withAuditID(handler http.Handler, newAuditIDFunc func() string) http.Handler { +func WithAuditID(handler http.Handler, newAuditIDFunc func() string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := audit.WithAuditContext(r.Context()) - r = r.WithContext(ctx) - - auditID := newAuditIDFunc() - audit.WithAuditID(ctx, types.UID(auditID)) + r, auditID := NewRequestWithAuditID(r, newAuditIDFunc) // Send the Audit-ID response header. w.Header().Set(apisaudit.HeaderAuditID, auditID) diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go index 727e031c3..bc0774521 100644 --- a/internal/plog/audit_event.go +++ b/internal/plog/audit_event.go @@ -21,7 +21,7 @@ const ( AuditEventIdentityRefreshedFromUpstreamIDP AuditEventMessage = "Identity Refreshed From Upstream IDP" AuditEventSessionStarted AuditEventMessage = "Session Started" AuditEventSessionRefreshed AuditEventMessage = "Session Refreshed" - AuditEventAuthenticationRejectedByTransforms AuditEventMessage = "Authentication RejectedBy Transforms" + AuditEventAuthenticationRejectedByTransforms AuditEventMessage = "Authentication Rejected By Transforms" AuditEventUpstreamOIDCTokenRevoked AuditEventMessage = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential AuditEventSessionGarbageCollected AuditEventMessage = "Session Garbage Collected" AuditEventTokenCredentialRequest AuditEventMessage = "TokenCredentialRequest" //nolint:gosec // this is not a credential diff --git a/internal/testutil/oidctestutil/session_storage_assertions.go b/internal/testutil/oidctestutil/session_storage_assertions.go index 0d5f896b0..143aac071 100644 --- a/internal/testutil/oidctestutil/session_storage_assertions.go +++ b/internal/testutil/oidctestutil/session_storage_assertions.go @@ -47,7 +47,7 @@ func RequireAuthCodeRegexpMatch( wantDownstreamRedirectURI string, wantCustomSessionData *psession.CustomSessionData, wantDownstreamAdditionalClaims map[string]any, -) { +) string { t.Helper() // Assert that Location header matches regular expression. @@ -73,7 +73,7 @@ func RequireAuthCodeRegexpMatch( // One authcode should have been stored. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) - storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage( + sessionID, storedRequestFromAuthcode, storedSessionFromAuthcode := validateAuthcodeStorage( t, oauthStore, authcodeDataAndSignature[1], // Authcode store key is authcode signature @@ -114,6 +114,8 @@ func RequireAuthCodeRegexpMatch( wantDownstreamNonce, ) } + + return sessionID } func includesOpenIDScope(scopes []string) bool { @@ -139,7 +141,7 @@ func validateAuthcodeStorage( wantDownstreamRedirectURI string, wantCustomSessionData *psession.CustomSessionData, wantDownstreamAdditionalClaims map[string]any, -) (*fosite.Request, *psession.PinnipedSession) { +) (string, *fosite.Request, *psession.PinnipedSession) { t.Helper() const ( @@ -151,6 +153,8 @@ func validateAuthcodeStorage( storedAuthorizeRequestFromAuthcode, err := oauthStore.GetAuthorizeCodeSession(context.Background(), storeKey, nil) require.NoError(t, err) + sessionID := storedAuthorizeRequestFromAuthcode.GetID() + // Check that storage returned the expected concrete data types. storedRequestFromAuthcode, storedSessionFromAuthcode := castStoredAuthorizeRequest(t, storedAuthorizeRequestFromAuthcode) @@ -258,7 +262,7 @@ func validateAuthcodeStorage( // Check that the custom Pinniped session data matches. require.Equal(t, wantCustomSessionData, storedSessionFromAuthcode.Custom) - return storedRequestFromAuthcode, storedSessionFromAuthcode + return sessionID, storedRequestFromAuthcode, storedSessionFromAuthcode } func validatePKCEStorage( From bf1e37f149179ddc6f8658b17232ae05ffbfdfa0 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 31 Oct 2024 10:15:27 -0500 Subject: [PATCH 05/71] Use a helper to verify audit messages --- .../downstreamsession/downstream_session.go | 10 +- .../endpoints/auth/auth_handler.go | 7 +- .../endpoints/auth/auth_handler_test.go | 264 ++++++++++++++---- .../endpoints/token/token_handler.go | 2 +- internal/testutil/log_lines.go | 58 ++++ 5 files changed, 272 insertions(+), 69 deletions(-) diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 96d7cce11..e22c9b3bf 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -49,8 +49,8 @@ func NewPinnipedSession( ) (*psession.PinnipedSession, error) { now := time.Now().UTC() - // Do not associate this audit event with a session ID, since the session has not yet "started", - // and this session may not be persisted to permanent storage. + // Do not associate this audit event with a session ID. + // The session has not yet "started" and may not be persisted to permanent storage. auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, nil, "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, "upstreamGroups", c.UpstreamIdentity.UpstreamGroups) @@ -58,10 +58,10 @@ func NewPinnipedSession( downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { - // Do not associate this audit event with a session ID, since we reject this session (and - // will never write it to permanent storage). + // Do not associate this audit event with a session ID. + // This session is being rejected and will never be persisted to permanent storage. auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, nil, - "err", err) + "reason", err) return nil, err } diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index b950eb73b..64a746d16 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -215,10 +215,13 @@ func (h *authorizeHandler) authorize( var authorizeID string authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) - h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), nil, - "authorizeID", authorizeID) + if err == nil { + h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), nil, + "authorizeID", authorizeID) + } } if err != nil { + // TODO: Consider an audit event here oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow) } } diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 651ac22d7..b9bf6f21f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -337,7 +337,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo } happyLDAPUsername := "some-ldap-user" - happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" + happyLDAPUsernameFromAuthenticator := "some-ldap-username-from-authenticator" happyLDAPPassword := "some-ldap-password" //nolint:gosec happyLDAPUID := "some-ldap-uid" happyLDAPUserDN := "cn=foo,dn=bar" @@ -666,6 +666,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo return hex.EncodeToString(upstreamStateHash[:]) } + buildWantedAuditLog := func(message string, params map[string]any) testutil.WantedAuditLog { + wantedAuditLog := testutil.WantedAuditLog{ + Message: message, + Params: params, + } + wantedAuditLog.Params["auditID"] = "some-audit-id" + wantedAuditLog.Params["timestamp"] = "2099-08-08T13:57:36.123456Z" + return wantedAuditLog + } + type testCase struct { name string @@ -693,7 +703,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref bool wantLocationHeader string wantUpstreamStateParamInLocationHeader bool - wantAuditLogs func(encodedStateParam, sessionID string) []string + wantAuditLogs func(encodedStateParam, sessionID string) []testutil.WantedAuditLog // Assertions for when an authcode should be returned, i.e. the request was authenticated by an // upstream LDAP provider or an upstream OIDC password grant flow. @@ -730,12 +740,24 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-oidc-idp", + "resourceName": "some-oidc-idp", + "resourceUID": "oidc-resource-uid", + "type": "oidc", + }), + buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": generateAuthorizeId(encodedStateParam), + }), } }, }, @@ -756,12 +778,24 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-oidc-idp", + "resourceName": "some-oidc-idp", + "resourceUID": "oidc-resource-uid", + "type": "oidc", + }), + buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": generateAuthorizeId(encodedStateParam), + }), } }, }, @@ -781,12 +815,24 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(nil, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-github-idp", + "resourceName": "some-github-idp", + "resourceUID": "github-resource-uid", + "type": "github", + }), + buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": generateAuthorizeId(encodedStateParam), + }), } }, }, @@ -807,12 +853,24 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-github-idp","resourceName":"some-github-idp","resourceUID":"github-resource-uid","type":"github"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":"` + generateAuthorizeId(encodedStateParam) + `"}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-github-idp", + "resourceName": "some-github-idp", + "resourceUID": "github-resource-uid", + "type": "github", + }), + buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": generateAuthorizeId(encodedStateParam), + }), } }, }, @@ -982,13 +1040,33 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-password-granting-oidc-idp","resourceName":"some-password-granting-oidc-idp","resourceUID":"some-password-granting-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"test-oidc-pinniped-username","upstreamGroups":["test-pinniped-group-0","test-pinniped-group-1"]}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"test-oidc-pinniped-username","groups":["test-pinniped-group-0","test-pinniped-group-1"],"subject":"https://my-upstream-issuer.com?idpName=some-password-granting-oidc-idp&sub=abc123-some+guid","additionalClaims":{},"warnings":[]}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-password-granting-oidc-idp", + "resourceName": "some-password-granting-oidc-idp", + "resourceUID": "some-password-granting-resource-uid", + "type": "oidc", + }), + buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }), + buildWantedAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "test-oidc-pinniped-username", + "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "subject": "https://my-upstream-issuer.com?idpName=some-password-granting-oidc-idp&sub=abc123-some+guid", + "additionalClaims": map[string]any{}, // json: {} + "warnings": []any{}, // json: [] + }), } }, }, @@ -1033,13 +1111,28 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery), wantBodyString: "", - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-password-granting-oidc-idp","resourceName":"some-password-granting-oidc-idp","resourceUID":"some-password-granting-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"test-oidc-pinniped-username","upstreamGroups":["test-pinniped-group-0","test-pinniped-group-1"]}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Authentication Rejected By Transforms","auditID":"some-audit-id","auditEvent":true,"err":"configured identity policy rejected this authentication: authentication was rejected by a configured policy"}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-password-granting-oidc-idp", + "resourceName": "some-password-granting-oidc-idp", + "resourceUID": "some-password-granting-resource-uid", + "type": "oidc", + }), + buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }), + buildWantedAuditLog("Authentication Rejected By Transforms", map[string]any{ + "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", + }), } }, }, @@ -1125,13 +1218,33 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"some-mapped-ldap-username","upstreamGroups":["group1","group2","group3"]}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"some-mapped-ldap-username","groups":["group1","group2","group3"],"subject":"ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid","additionalClaims":null,"warnings":[]}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }), + buildWantedAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "some-ldap-username-from-authenticator", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": nil, // json: null + "warnings": []any{}, // json: [] + }), } }, }, @@ -1161,13 +1274,33 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo happyLDAPUsernameFromAuthenticator, happyLDAPGroups, ), - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":true,"Pinniped-Password":true}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-ldap-idp","resourceName":"some-ldap-idp","resourceUID":"ldap-resource-uid","type":"ldap"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Identity From Upstream IDP","auditID":"some-audit-id","auditEvent":true,"upstreamUsername":"some-mapped-ldap-username","upstreamGroups":["group1","group2","group3"]}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"downstreamsession/downstream_session.go:$downstreamsession.NewPinnipedSession","message":"Session Started","sessionID":"` + sessionID + `","auditID":"some-audit-id","auditEvent":true,"username":"username_prefix:some-mapped-ldap-username","groups":["groups_prefix:group1","groups_prefix:group2","groups_prefix:group3"],"subject":"ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid","additionalClaims":null,"warnings":[]}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }), + buildWantedAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "username_prefix:some-ldap-username-from-authenticator", + "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": nil, // json: null + "warnings": []any{}, // json: [] + }), } }, }, @@ -1509,12 +1642,21 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), wantBodyString: "", - wantAuditLogs: func(encodedStateParam, sessionID string) []string { - return []string{ - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Custom Headers Used","auditID":"some-audit-id","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"HTTP Request Parameters","auditID":"some-audit-id","auditEvent":true,"params":"client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).ServeHTTP","message":"Using Upstream IDP","auditID":"some-audit-id","auditEvent":true,"displayName":"some-oidc-idp","resourceName":"some-oidc-idp","resourceUID":"oidc-resource-uid","type":"oidc"}`, - `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"auth/auth_handler.go:$auth.(*authorizeHandler).authorize","message":"Upstream Authorize Redirect","auditID":"some-audit-id","auditEvent":true,"authorizeID":""}`, + wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + buildWantedAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-oidc-idp", + "resourceName": "some-oidc-idp", + "resourceUID": "oidc-resource-uid", + "type": "oidc", + }), } }, }, @@ -3665,7 +3807,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(actualQueryStateParam, sessionID) - testutil.RequireLogLines(t, wantAuditLogs, auditLog) + testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) } switch { diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 83bdf5f0d..089b8043e 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -234,7 +234,7 @@ func upstreamRefresh( ) if err != nil { auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, accessRequest, - "err", err) + "reason", err) return err } diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 3b9e321a4..7e2c1bd40 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -5,6 +5,7 @@ package testutil import ( "bytes" + "encoding/json" "strings" "testing" @@ -20,3 +21,60 @@ func RequireLogLines(t *testing.T, wantLogs []string, log *bytes.Buffer) { } require.Equal(t, expectedLogs, log.String()) } + +type WantedAuditLog struct { + Message string + Params map[string]any +} + +//"message":"HTTP Request Custom Headers Used", +//"auditID":"some-audit-id", +//"Pinniped-Username":false,"Pinniped-Password":false}`, + +func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { + t.Helper() + + var wantJsonAuditLogs []map[string]any + var wantMessages []string + for _, wantAuditLog := range wantAuditLogs { + wantJsonAuditLog := make(map[string]any) + wantJsonAuditLog["level"] = "info" + wantJsonAuditLog["message"] = wantAuditLog.Message + wantMessages = append(wantMessages, wantAuditLog.Message) + wantJsonAuditLog["auditEvent"] = true + for k, v := range wantAuditLog.Params { + wantJsonAuditLog[k] = v + } + wantJsonAuditLogs = append(wantJsonAuditLogs, wantJsonAuditLog) + } + + var actualJsonAuditLogs []map[string]any + var actualMessages []string + actualAuditLogs := strings.Split(actualAuditLogsOneLiner, "\n") + require.GreaterOrEqual(t, len(actualAuditLogs), 2) + actualAuditLogs = actualAuditLogs[:len(actualAuditLogs)-1] // trim off the last "" + for _, actualAuditLog := range actualAuditLogs { + actualJsonAuditLog := make(map[string]any) + err := json.Unmarshal([]byte(actualAuditLog), &actualJsonAuditLog) + require.NoError(t, err) + + // we don't care to test the caller + delete(actualJsonAuditLog, "caller") + actualJsonAuditLogs = append(actualJsonAuditLogs, actualJsonAuditLog) + + actualMessage, ok := actualJsonAuditLog["message"].(string) + require.True(t, ok, "actual message is not a string, instead %+v", actualJsonAuditLog["message"]) + actualMessages = append(actualMessages, actualMessage) + } + + // We should check array indices first so that we don't exceed any boundaries. + // But we also want to be sure to indicate to the caller what went wrong, so compare the messages. + require.Equal(t, wantMessages, actualMessages) + + // We can expect the audit logs to be ordered deterministically. + for i := range len(wantJsonAuditLogs) { + // compare each item individually so we know which message it is + require.Equal(t, wantJsonAuditLogs[i], actualJsonAuditLogs[i], + "audit log for message %q does not match", wantJsonAuditLogs[i]["message"]) + } +} From 44e218194b3c36d8928bb1d080a5e2a0b5e9b2e2 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 31 Oct 2024 17:00:52 -0500 Subject: [PATCH 06/71] Add 'AuthorizeID From Parameters' audit logs to the /callback and /login endpoints Co-authored-by: Ryan Richard --- internal/crypto/ptls/log_profiles_test.go | 5 +- .../downstreamsession/downstream_session.go | 12 +- .../endpoints/auth/auth_handler.go | 19 +- .../endpoints/auth/auth_handler_test.go | 229 ++++++++++++------ .../endpoints/callback/callback_handler.go | 24 +- .../callback/callback_handler_test.go | 10 +- .../endpoints/login/get_login_handler.go | 5 +- .../endpoints/login/get_login_handler_test.go | 7 +- .../endpoints/login/login_handler.go | 7 +- .../endpoints/login/login_handler_test.go | 22 +- .../endpoints/login/post_login_handler.go | 5 +- .../endpoints/loginurl/login_url.go | 5 +- .../endpointsmanager/manager.go | 1 + internal/federationdomain/oidc/oidc.go | 5 +- .../requestlogger/request_logger.go | 17 ++ .../resolvedprovider/resolved_provider.go | 3 +- .../resolved_github_provider.go | 2 +- .../resolvedoidc/resolved_oidc_provider.go | 2 +- .../federationdomain/stateparam/encoded.go | 19 ++ .../stateparam/encoded_test.go | 22 ++ internal/plog/audit_event.go | 1 + internal/plog/plog.go | 12 + internal/testutil/log_lines.go | 24 +- .../expected_upstream_state_param.go | 5 +- 24 files changed, 321 insertions(+), 142 deletions(-) create mode 100644 internal/federationdomain/stateparam/encoded.go create mode 100644 internal/federationdomain/stateparam/encoded_test.go diff --git a/internal/crypto/ptls/log_profiles_test.go b/internal/crypto/ptls/log_profiles_test.go index 9f5c39153..497dac13b 100644 --- a/internal/crypto/ptls/log_profiles_test.go +++ b/internal/crypto/ptls/log_profiles_test.go @@ -1,11 +1,12 @@ // Copyright 2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package ptls +package ptls_test import ( "testing" + "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" ) @@ -13,7 +14,7 @@ import ( func TestLogAllProfiles(t *testing.T) { logger, log := plog.TestLogger(t) - LogAllProfiles(logger) + ptls.LogAllProfiles(logger) expectedLines := []string{ `{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"ptls/log_profiles.go:$ptls.logProfile","message":"tls configuration","profile name":"Default","MinVersion":"TLS 1.2","MaxVersion":"NONE","CipherSuites":["TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256","TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384","TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256","TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"],"NextProtos":["h2","http/1.1"]}`, diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index e22c9b3bf..7dd1d377e 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -49,18 +49,18 @@ func NewPinnipedSession( ) (*psession.PinnipedSession, error) { now := time.Now().UTC() - // Do not associate this audit event with a session ID. - // The session has not yet "started" and may not be persisted to permanent storage. - auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, nil, + auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, plog.NoSessionPersisted(), + "upstreamIDPDisplayName", c.IdentityProvider.GetDisplayName(), + "upstreamIDPType", c.IdentityProvider.GetSessionProviderType(), + "upstreamIDPResourceName", c.IdentityProvider.GetProvider().GetResourceName(), + "upstreamIDPResourceUID", c.IdentityProvider.GetProvider().GetResourceUID(), "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, "upstreamGroups", c.UpstreamIdentity.UpstreamGroups) downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { - // Do not associate this audit event with a session ID. - // This session is being rejected and will never be persisted to permanent storage. - auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, nil, + auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, plog.NoSessionPersisted(), "reason", err) return nil, err } diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 64a746d16..78cac0624 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -5,8 +5,6 @@ package auth import ( - "crypto/sha256" - "encoding/hex" "fmt" "net/http" "net/url" @@ -24,6 +22,7 @@ import ( "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/resolvedprovider" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/responseutil" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/plog" @@ -136,11 +135,11 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Log if these headers were present, but don't log the actual values. The password is obviously sensitive, // and sometimes users use their password as their username by mistake. - h.auditLogger.Audit(plog.AuditEventHTTPRequestCustomHeadersUsed, r.Context(), nil, + h.auditLogger.Audit(plog.AuditEventHTTPRequestCustomHeadersUsed, r.Context(), plog.NoSessionPersisted(), oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader, oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) - h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), nil, + h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), "params", plog.SanitizeParams(r.Form, paramsSafeToLog)) // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and @@ -172,7 +171,7 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.auditLogger.Audit(plog.AuditEventUsingUpstreamIDP, r.Context(), nil, + h.auditLogger.Audit(plog.AuditEventUsingUpstreamIDP, r.Context(), plog.NoSessionPersisted(), "displayName", idp.GetDisplayName(), "resourceName", idp.GetProvider().GetResourceName(), "resourceUID", idp.GetProvider().GetResourceUID(), @@ -216,7 +215,7 @@ func (h *authorizeHandler) authorize( authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) if err == nil { - h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), nil, + h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), plog.NoSessionPersisted(), "authorizeID", authorizeID) } } @@ -295,9 +294,7 @@ func (h *authorizeHandler) authorizeWithBrowser( http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11 ) - upstreamStateHash := sha256.Sum256([]byte(authRequestState.EncodedStateParam)) - authorizeID := hex.EncodeToString(upstreamStateHash[:]) - return authorizeID, nil + return authRequestState.EncodedStateParam.AuthorizeID(), nil } func shouldShowIDPChooser( @@ -473,7 +470,7 @@ func upstreamStateParam( csrfValue csrftoken.CSRFToken, pkceValue pkce.Code, encoder oidc.Encoder, -) (string, error) { +) (stateparam.Encoded, error) { stateParamData := oidc.UpstreamStateParamData{ // The auth params might have included oidcapi.AuthorizeUpstreamIDPNameParamName and // oidcapi.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers @@ -492,7 +489,7 @@ func upstreamStateParam( if err != nil { return "", fmt.Errorf("error encoding upstream state param: %w", err) } - return encodedStateParamValue, nil + return stateparam.Encoded(encodedStateParamValue), nil } func removeCustomIDPParams(params url.Values) url.Values { diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index b9bf6f21f..e5db3cdc3 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -6,8 +6,6 @@ package auth import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "html" @@ -38,6 +36,7 @@ import ( "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/requestlogger" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" @@ -661,19 +660,8 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) - generateAuthorizeId := func(encodedStateParam string) string { - upstreamStateHash := sha256.Sum256([]byte(encodedStateParam)) - return hex.EncodeToString(upstreamStateHash[:]) - } - - buildWantedAuditLog := func(message string, params map[string]any) testutil.WantedAuditLog { - wantedAuditLog := testutil.WantedAuditLog{ - Message: message, - Params: params, - } - wantedAuditLog.Params["auditID"] = "some-audit-id" - wantedAuditLog.Params["timestamp"] = "2099-08-08T13:57:36.123456Z" - return wantedAuditLog + wantAuditLog := func(message string, params map[string]any) testutil.WantedAuditLog { + return testutil.WantAuditLog(message, params, "some-audit-id") } type testCase struct { @@ -703,7 +691,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref bool wantLocationHeader string wantUpstreamStateParamInLocationHeader bool - wantAuditLogs func(encodedStateParam, sessionID string) []testutil.WantedAuditLog + wantAuditLogs func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog // Assertions for when an authcode should be returned, i.e. the request was authenticated by an // upstream LDAP provider or an upstream OIDC password grant flow. @@ -740,23 +728,23 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ - "authorizeID": generateAuthorizeId(encodedStateParam), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), }), } }, @@ -778,23 +766,23 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ - "authorizeID": generateAuthorizeId(encodedStateParam), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), }), } }, @@ -815,23 +803,23 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(nil, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", "resourceUID": "github-resource-uid", "type": "github", }), - buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ - "authorizeID": generateAuthorizeId(encodedStateParam), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), }), } }, @@ -853,23 +841,23 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamGithub(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", githubUpstreamName, "github")), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", "resourceUID": "github-resource-uid", "type": "github", }), - buildWantedAuditLog("Upstream Authorize Redirect", map[string]any{ - "authorizeID": generateAuthorizeId(encodedStateParam), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), }), } }, @@ -890,6 +878,26 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + wantAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + wantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "OIDC upstream browser flow happy path using GET without a CSRF cookie using backwards compatibility mode to have a default IDP (display name does not need to be sent as query param)", @@ -908,6 +916,26 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + wantAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + wantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-oidc-idp", + "resourceName": "some-oidc-idp", + "resourceUID": "oidc-resource-uid", + "type": "oidc", + }), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "with multiple IDPs available, request does not choose which IDP to use", @@ -927,6 +955,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: urlWithQuery(downstreamIssuer+"/choose_identity_provider", happyGetRequestQueryMap), wantUpstreamStateParamInLocationHeader: false, // it should copy the params of the original request, not add a new state param wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + wantAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + } + }, }, { name: "with multiple IDPs available, request chooses to use OIDC browser flow", @@ -946,6 +985,26 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + wantAuditLog("HTTP Request Parameters", map[string]any{ + "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + }), + wantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-oidc-idp", + "resourceName": "some-oidc-idp", + "resourceUID": "oidc-resource-uid", + "type": "oidc", + }), + wantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "with multiple IDPs available, request chooses to use LDAP browser flow", @@ -1040,26 +1099,30 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", "resourceUID": "some-password-granting-resource-uid", "type": "oidc", }), - buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ - "upstreamUsername": "test-oidc-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + wantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-password-granting-oidc-idp", + "upstreamIDPResourceName": "some-password-granting-oidc-idp", + "upstreamIDPResourceUID": "some-password-granting-resource-uid", + "upstreamIDPType": "oidc", + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, }), - buildWantedAuditLog("Session Started", map[string]any{ + wantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "test-oidc-pinniped-username", "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, @@ -1111,26 +1174,30 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery), wantBodyString: "", - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", "resourceUID": "some-password-granting-resource-uid", "type": "oidc", }), - buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ - "upstreamUsername": "test-oidc-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + wantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-password-granting-oidc-idp", + "upstreamIDPResourceName": "some-password-granting-oidc-idp", + "upstreamIDPResourceUID": "some-password-granting-resource-uid", + "upstreamIDPType": "oidc", + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, }), - buildWantedAuditLog("Authentication Rejected By Transforms", map[string]any{ + wantAuditLog("Authentication Rejected By Transforms", map[string]any{ "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", }), } @@ -1218,26 +1285,30 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", "resourceUID": "ldap-resource-uid", "type": "ldap", }), - buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ - "upstreamUsername": "some-ldap-username-from-authenticator", - "upstreamGroups": []any{"group1", "group2", "group3"}, + wantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "upstreamIDPType": "ldap", + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, }), - buildWantedAuditLog("Session Started", map[string]any{ + wantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "some-ldap-username-from-authenticator", "groups": []any{"group1", "group2", "group3"}, @@ -1274,26 +1345,30 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo happyLDAPUsernameFromAuthenticator, happyLDAPGroups, ), - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", "resourceUID": "ldap-resource-uid", "type": "ldap", }), - buildWantedAuditLog("Identity From Upstream IDP", map[string]any{ - "upstreamUsername": "some-ldap-username-from-authenticator", - "upstreamGroups": []any{"group1", "group2", "group3"}, + wantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "upstreamIDPType": "ldap", + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, }), - buildWantedAuditLog("Session Started", map[string]any{ + wantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "username_prefix:some-ldap-username-from-authenticator", "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, @@ -1642,16 +1717,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), wantBodyString: "", - wantAuditLogs: func(encodedStateParam, sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - buildWantedAuditLog("HTTP Request Custom Headers Used", map[string]any{ + wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - buildWantedAuditLog("HTTP Request Parameters", map[string]any{ + wantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - buildWantedAuditLog("Using Upstream IDP", map[string]any{ + wantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", @@ -3806,7 +3881,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo } if test.wantAuditLogs != nil { - wantAuditLogs := test.wantAuditLogs(actualQueryStateParam, sessionID) + wantAuditLogs := test.wantAuditLogs(stateparam.Encoded(actualQueryStateParam), sessionID) testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) } diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 5c8d32165..60853f6b1 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -14,6 +14,7 @@ import ( "go.pinniped.dev/internal/federationdomain/federationdomainproviders" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/plog" @@ -27,18 +28,21 @@ func NewHandler( auditLogger plog.AuditLogger, ) http.Handler { handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - state, err := validateRequest(r, stateDecoder, cookieDecoder) + encodedState, decodedState, err := validateRequest(r, stateDecoder, cookieDecoder) if err != nil { return err } - idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(state.UpstreamName) + auditLogger.Audit(plog.AuditEventAuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), + "authorizeID", encodedState.AuthorizeID()) + + idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(decodedState.UpstreamName) if err != nil || idp == nil { plog.Warning("upstream provider not found") return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found") } - downstreamAuthParams, err := url.ParseQuery(state.AuthParams) + downstreamAuthParams, err := url.ParseQuery(decodedState.AuthParams) if err != nil { plog.Error("error reading state downstream auth params", err) return httperr.New(http.StatusBadRequest, "error reading state downstream auth params") @@ -61,7 +65,7 @@ func NewHandler( // an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here. downstreamsession.AutoApproveScopes(authorizeRequester) - identity, loginExtras, err := idp.LoginFromCallback(r.Context(), authcode(r), state.PKCECode, state.Nonce, redirectURI) + identity, loginExtras, err := idp.LoginFromCallback(r.Context(), authcode(r), decodedState.PKCECode, decodedState.Nonce, redirectURI) if err != nil { plog.WarningErr("unable to complete login from callback", err, "identityProviderDisplayName", idp.GetDisplayName(), @@ -107,21 +111,21 @@ func authcode(r *http.Request) string { return r.FormValue("code") } -func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) (*oidc.UpstreamStateParamData, error) { +func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) (stateparam.Encoded, *oidc.UpstreamStateParamData, error) { if r.Method != http.MethodGet { - return nil, httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET)", r.Method) + return "", nil, httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET)", r.Method) } - _, decodedState, err := oidc.ReadStateParamAndValidateCSRFCookie(r, cookieDecoder, stateDecoder) + encodedState, decodedState, err := oidc.ReadStateParamAndValidateCSRFCookie(r, cookieDecoder, stateDecoder) if err != nil { plog.InfoErr("state or CSRF error", err) - return nil, err + return "", nil, err } if authcode(r) == "" { plog.Info("code param not found") - return nil, httperr.New(http.StatusBadRequest, "code param not found") + return "", nil, httperr.New(http.StatusBadRequest, "code param not found") } - return decodedState, nil + return encodedState, decodedState, nil } diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 366929aaf..1e4189aa2 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -25,6 +25,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" @@ -1870,12 +1871,13 @@ type expectedGitHubAuthcodeExchange struct { } type requestPath struct { - code, state *string + code *string + state *stateparam.Encoded } func newRequestPath() *requestPath { c := happyUpstreamAuthcode - s := "4321" + s := stateparam.Encoded("4321") return &requestPath{ code: &c, state: &s, @@ -1892,7 +1894,7 @@ func (r *requestPath) WithoutCode() *requestPath { return r } -func (r *requestPath) WithState(state string) *requestPath { +func (r *requestPath) WithState(state stateparam.Encoded) *requestPath { r.state = &state return r } @@ -1909,7 +1911,7 @@ func (r *requestPath) String() string { params.Add("code", *r.code) } if r.state != nil { - params.Add("state", *r.state) + params.Add("state", r.state.String()) } return path + params.Encode() } diff --git a/internal/federationdomain/endpoints/login/get_login_handler.go b/internal/federationdomain/endpoints/login/get_login_handler.go index 8b5beb2ff..772ea224c 100644 --- a/internal/federationdomain/endpoints/login/get_login_handler.go +++ b/internal/federationdomain/endpoints/login/get_login_handler.go @@ -9,6 +9,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" "go.pinniped.dev/internal/federationdomain/endpoints/loginurl" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" ) const ( @@ -17,12 +18,12 @@ const ( ) func NewGetHandler(loginPath string) HandlerFunc { - return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { + return func(w http.ResponseWriter, r *http.Request, encodedState stateparam.Encoded, decodedState *oidc.UpstreamStateParamData) error { alertMessage, hasAlert := getAlert(r) pageInputs := &loginhtml.PageData{ PostPath: loginPath, - State: encodedState, + State: encodedState.String(), IDPName: decodedState.UpstreamName, HasAlertError: hasAlert, AlertMessage: alertMessage, diff --git a/internal/federationdomain/endpoints/login/get_login_handler_test.go b/internal/federationdomain/endpoints/login/get_login_handler_test.go index 74405d497..7ff8a7714 100644 --- a/internal/federationdomain/endpoints/login/get_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/get_login_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -13,6 +13,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" "go.pinniped.dev/internal/federationdomain/idplister" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/testutil" ) @@ -27,7 +28,7 @@ func TestGetLogin(t *testing.T) { tests := []struct { name string decodedState *oidc.UpstreamStateParamData - encodedState string + encodedState stateparam.Encoded errParam string idps idplister.UpstreamIdentityProvidersLister wantStatus int @@ -98,7 +99,7 @@ func TestGetLogin(t *testing.T) { t.Parallel() handler := NewGetHandler(testPath) - target := testPath + "?state=" + tt.encodedState + target := testPath + "?state=" + tt.encodedState.String() if tt.errParam != "" { target += "&err=" + tt.errParam } diff --git a/internal/federationdomain/endpoints/login/login_handler.go b/internal/federationdomain/endpoints/login/login_handler.go index 2b2bf4491..e37c6d40b 100644 --- a/internal/federationdomain/endpoints/login/login_handler.go +++ b/internal/federationdomain/endpoints/login/login_handler.go @@ -10,6 +10,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/plog" @@ -19,7 +20,7 @@ import ( type HandlerFunc func( w http.ResponseWriter, r *http.Request, - encodedState string, + encodedState stateparam.Encoded, decodedState *oidc.UpstreamStateParamData, ) error @@ -38,6 +39,7 @@ func NewHandler( cookieDecoder oidc.Decoder, getHandler HandlerFunc, // use NewGetHandler() for production postHandler HandlerFunc, // use NewPostHandler() for production + auditLogger plog.AuditLogger, ) http.Handler { loginHandler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { var handler HandlerFunc @@ -56,6 +58,9 @@ func NewHandler( return err } + auditLogger.Audit(plog.AuditEventAuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), + "authorizeID", encodedState.AuthorizeID()) + switch decodedState.UpstreamType { case string(idpdiscoveryv1alpha1.IDPTypeLDAP), string(idpdiscoveryv1alpha1.IDPTypeActiveDirectory): // these are the types supported by this endpoint, so no error here diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 854484b35..3e761d442 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -14,7 +14,9 @@ import ( "github.com/stretchr/testify/require" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" ) @@ -118,7 +120,7 @@ func TestLoginEndpoint(t *testing.T) { wantStatus int wantContentType string wantBody string - wantEncodedState string + wantEncodedState stateparam.Encoded wantDecodedState *oidc.UpstreamStateParamData }{ { @@ -381,12 +383,12 @@ func TestLoginEndpoint(t *testing.T) { testGetHandler := func( w http.ResponseWriter, r *http.Request, - encodedState string, + encodedState stateparam.Encoded, decodedState *oidc.UpstreamStateParamData, ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, tt.wantEncodedState, encodedState) + require.Equal(t, stateparam.Encoded(tt.wantEncodedState), encodedState) require.Equal(t, tt.wantDecodedState, decodedState) if tt.getHandlerErr == nil { _, err := w.Write([]byte(happyGetResult)) @@ -398,12 +400,12 @@ func TestLoginEndpoint(t *testing.T) { testPostHandler := func( w http.ResponseWriter, r *http.Request, - encodedState string, + encodedState stateparam.Encoded, decodedState *oidc.UpstreamStateParamData, ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, tt.wantEncodedState, encodedState) + require.Equal(t, stateparam.Encoded(tt.wantEncodedState), encodedState) require.Equal(t, tt.wantDecodedState, decodedState) if tt.postHandlerErr == nil { _, err := w.Write([]byte(happyPostResult)) @@ -412,7 +414,7 @@ func TestLoginEndpoint(t *testing.T) { return tt.postHandlerErr } - subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler) + subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, plog.New()) subject.ServeHTTP(rsp, req) @@ -430,14 +432,14 @@ func TestLoginEndpoint(t *testing.T) { } type requestPath struct { - state *string + state *stateparam.Encoded } func newRequestPath() *requestPath { return &requestPath{} } -func (r *requestPath) WithState(state string) *requestPath { +func (r *requestPath) WithState(state stateparam.Encoded) *requestPath { r.state = &state return r } @@ -451,7 +453,7 @@ func (r *requestPath) String() string { path := "/login?" params := url.Values{} if r.state != nil { - params.Add("state", *r.state) + params.Add("state", r.state.String()) } return path + params.Encode() } diff --git a/internal/federationdomain/endpoints/login/post_login_handler.go b/internal/federationdomain/endpoints/login/post_login_handler.go index 084a530ae..176782f00 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler.go +++ b/internal/federationdomain/endpoints/login/post_login_handler.go @@ -15,6 +15,7 @@ import ( "go.pinniped.dev/internal/federationdomain/federationdomainproviders" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/plog" ) @@ -25,7 +26,7 @@ func NewPostHandler( oauthHelper fosite.OAuth2Provider, auditLogger plog.AuditLogger, ) HandlerFunc { - return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { + return func(w http.ResponseWriter, r *http.Request, encodedState stateparam.Encoded, decodedState *oidc.UpstreamStateParamData) error { // Note that the login handler prevents this handler from being called with OIDC upstreams. idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(decodedState.UpstreamName) if err != nil { @@ -114,7 +115,7 @@ func redirectToLoginPage( r *http.Request, w http.ResponseWriter, downstreamIssuer string, - encodedStateParamValue string, + encodedStateParamValue stateparam.Encoded, errToDisplay loginurl.ErrorParamValue, ) error { loginURL, err := loginurl.URL(downstreamIssuer, encodedStateParamValue, errToDisplay) diff --git a/internal/federationdomain/endpoints/loginurl/login_url.go b/internal/federationdomain/endpoints/loginurl/login_url.go index f7d1b7911..c64205eee 100644 --- a/internal/federationdomain/endpoints/loginurl/login_url.go +++ b/internal/federationdomain/endpoints/loginurl/login_url.go @@ -7,6 +7,7 @@ import ( "net/url" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/stateparam" ) const ( @@ -27,7 +28,7 @@ type ErrorParamValue string // provider.FederationDomainIssuer when the issuer string comes from that type. func URL( downstreamIssuer string, - encodedStateParamValue string, + encodedStateParamValue stateparam.Encoded, errToDisplay ErrorParamValue, ) (string, error) { loginURL, err := url.Parse(downstreamIssuer + oidc.PinnipedLoginPath) @@ -36,7 +37,7 @@ func URL( } q := loginURL.Query() - q.Set(StateParamName, encodedStateParamValue) + q.Set(StateParamName, encodedStateParamValue.String()) if errToDisplay != ShowNoError { q.Set(ErrParamName, string(errToDisplay)) } diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index 0512d1b3e..aa5d2602d 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -184,6 +184,7 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro csrfCookieEncoder, login.NewGetHandler(incomingFederationDomain.IssuerPath()+oidc.PinnipedLoginPath), login.NewPostHandler(issuerURL, idpLister, oauthHelperWithKubeStorage, m.auditLogger), + m.auditLogger, ) plog.Debug("oidc provider manager added or updated issuer", "issuer", issuerURL) diff --git a/internal/federationdomain/oidc/oidc.go b/internal/federationdomain/oidc/oidc.go index 88354159f..db187acfc 100644 --- a/internal/federationdomain/oidc/oidc.go +++ b/internal/federationdomain/oidc/oidc.go @@ -25,6 +25,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/tokenexchange" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/idtokenlifespan" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/strategy" "go.pinniped.dev/internal/federationdomain/timeouts" "go.pinniped.dev/internal/httputil/httperr" @@ -326,7 +327,7 @@ func ScopeWasRequested(authorizeRequester fosite.AuthorizeRequester, scopeName s return false } -func ReadStateParamAndValidateCSRFCookie(r *http.Request, cookieDecoder Decoder, stateDecoder Decoder) (string, *UpstreamStateParamData, error) { +func ReadStateParamAndValidateCSRFCookie(r *http.Request, cookieDecoder Decoder, stateDecoder Decoder) (stateparam.Encoded, *UpstreamStateParamData, error) { csrfValue, err := readCSRFCookie(r, cookieDecoder) if err != nil { return "", nil, err @@ -342,7 +343,7 @@ func ReadStateParamAndValidateCSRFCookie(r *http.Request, cookieDecoder Decoder, return "", nil, err } - return encodedState, decodedState, nil + return stateparam.Encoded(encodedState), decodedState, nil } func readCSRFCookie(r *http.Request, cookieDecoder Decoder) (csrftoken.CSRFToken, error) { diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 7657cebbd..cc4609209 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -7,9 +7,11 @@ import ( "bufio" "net" "net/http" + "net/url" "time" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" apisaudit "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/endpoints/responsewriter" @@ -92,12 +94,27 @@ func (rl *requestLogger) LogRequestReceived() { func (rl *requestLogger) LogRequestComplete() { r := rl.req + location := rl.w.Header().Get("Location") + if location == "" { + location = "no location header" + } else { + parsedLocation, err := url.Parse(location) + if err != nil { + location = "unparsable location header" + } else { + redactAllParams := sets.New[string]() + parsedLocation.RawQuery = plog.SanitizeParams(parsedLocation.Query(), redactAllParams) + location = parsedLocation.String() + } + } + rl.auditLogger.Audit(plog.AuditEventHTTPRequestCompleted, r.Context(), nil, // no session available yet in this context "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events "latency", time.Since(rl.startTime), "responseStatus", rl.status, + "location", location, ) } diff --git a/internal/federationdomain/resolvedprovider/resolved_provider.go b/internal/federationdomain/resolvedprovider/resolved_provider.go index 226564245..664e94da8 100644 --- a/internal/federationdomain/resolvedprovider/resolved_provider.go +++ b/internal/federationdomain/resolvedprovider/resolved_provider.go @@ -10,6 +10,7 @@ import ( "github.com/ory/fosite" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/psession" @@ -86,7 +87,7 @@ type RefreshedIdentity struct { // upstream authorization request does not allow PKCE, then implementations of // FederationDomainResolvedIdentityProvider.UpstreamAuthorizeRedirectURL may choose to ignore that struct field. type UpstreamAuthorizeRequestState struct { - EncodedStateParam string + EncodedStateParam stateparam.Encoded PKCE pkce.Code Nonce nonce.Nonce } diff --git a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go index 9bec7de01..79ccc08f8 100644 --- a/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedgithub/resolved_github_provider.go @@ -81,7 +81,7 @@ func (p *FederationDomainResolvedGitHubIdentityProvider) UpstreamAuthorizeRedire RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuerURL), Scopes: p.Provider.GetScopes(), } - redirectURL := upstreamOAuthConfig.AuthCodeURL(state.EncodedStateParam) + redirectURL := upstreamOAuthConfig.AuthCodeURL(state.EncodedStateParam.String()) return redirectURL, nil } diff --git a/internal/federationdomain/resolvedprovider/resolvedoidc/resolved_oidc_provider.go b/internal/federationdomain/resolvedprovider/resolvedoidc/resolved_oidc_provider.go index a2e3644dd..be6ad5836 100644 --- a/internal/federationdomain/resolvedprovider/resolvedoidc/resolved_oidc_provider.go +++ b/internal/federationdomain/resolvedprovider/resolvedoidc/resolved_oidc_provider.go @@ -118,7 +118,7 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamAuthorizeRedirect } redirectURL := upstreamOAuthConfig.AuthCodeURL( - state.EncodedStateParam, + state.EncodedStateParam.String(), authCodeOptions..., ) diff --git a/internal/federationdomain/stateparam/encoded.go b/internal/federationdomain/stateparam/encoded.go new file mode 100644 index 000000000..38b65bdf6 --- /dev/null +++ b/internal/federationdomain/stateparam/encoded.go @@ -0,0 +1,19 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package stateparam + +import ( + "crypto/sha256" + "fmt" +) + +type Encoded string + +func (e Encoded) String() string { + return string(e) +} + +func (e Encoded) AuthorizeID() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(e))) +} diff --git a/internal/federationdomain/stateparam/encoded_test.go b/internal/federationdomain/stateparam/encoded_test.go new file mode 100644 index 000000000..cf00ad2df --- /dev/null +++ b/internal/federationdomain/stateparam/encoded_test.go @@ -0,0 +1,22 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package stateparam + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAuthorizeID(t *testing.T) { + // $ echo -n "foo" | shasum -a 256 + // 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae + require.Equal(t, "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + Encoded("foo").AuthorizeID()) + + // $ echo -n "" | shasum -a 256 + // e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + require.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Encoded("").AuthorizeID()) +} diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go index bc0774521..752a692f9 100644 --- a/internal/plog/audit_event.go +++ b/internal/plog/audit_event.go @@ -17,6 +17,7 @@ const ( AuditEventHTTPRequestParameters AuditEventMessage = "HTTP Request Parameters" AuditEventHTTPRequestCustomHeadersUsed AuditEventMessage = "HTTP Request Custom Headers Used" AuditEventUsingUpstreamIDP AuditEventMessage = "Using Upstream IDP" + AuditEventAuthorizeIDFromParameters AuditEventMessage = "AuthorizeID From Parameters" AuditEventIdentityFromUpstreamIDP AuditEventMessage = "Identity From Upstream IDP" AuditEventIdentityRefreshedFromUpstreamIDP AuditEventMessage = "Identity Refreshed From Upstream IDP" AuditEventSessionStarted AuditEventMessage = "Session Started" diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 894b0acc7..6253e8027 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -42,6 +42,18 @@ type SessionIDGetter interface { GetID() string } +// NoSessionPersisted means do not associate this audit event with a session ID. +// The session has not yet "started" and may or may not ever be persisted to permanent storage. +func NoSessionPersisted() SessionIDGetter { + return nil +} + +// NoHTTPRequestAvailable means there is no request context for this audit event. +// Use this when an audit event is emitted from a controller or some other place that does not have a request context. +func NoHTTPRequestAvailable() context.Context { + return nil +} + // AuditLogger is only the audit logging part of Logger. There is no global function for Audit because // that would make unit testing of audit logs harder. type AuditLogger interface { diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 7e2c1bd40..7f1cd7600 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -27,9 +27,16 @@ type WantedAuditLog struct { Params map[string]any } -//"message":"HTTP Request Custom Headers Used", -//"auditID":"some-audit-id", -//"Pinniped-Username":false,"Pinniped-Password":false}`, +func WantAuditLog(message string, params map[string]any, auditID string) WantedAuditLog { + result := WantedAuditLog{ + Message: message, + Params: params, + } + if auditID != "" { + result.Params["auditID"] = auditID + } + return result +} func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { t.Helper() @@ -42,6 +49,7 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL wantJsonAuditLog["message"] = wantAuditLog.Message wantMessages = append(wantMessages, wantAuditLog.Message) wantJsonAuditLog["auditEvent"] = true + wantJsonAuditLog["timestamp"] = "2099-08-08T13:57:36.123456Z" for k, v := range wantAuditLog.Params { wantJsonAuditLog[k] = v } @@ -58,7 +66,10 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL err := json.Unmarshal([]byte(actualAuditLog), &actualJsonAuditLog) require.NoError(t, err) - // we don't care to test the caller + // we don't care to test exact equality on the caller - just make sure it is a non-empty string + caller, ok := actualJsonAuditLog["caller"] + require.True(t, ok) + require.NotEmpty(t, caller, "caller for message %q must not be empty", actualJsonAuditLog["message"]) delete(actualJsonAuditLog, "caller") actualJsonAuditLogs = append(actualJsonAuditLogs, actualJsonAuditLog) @@ -67,6 +78,9 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL actualMessages = append(actualMessages, actualMessage) } + // TODO: remove this + t.Logf("LAST AUDIT EVENT: %s", actualAuditLogs[len(actualAuditLogs)-1]) + // We should check array indices first so that we don't exceed any boundaries. // But we also want to be sure to indicate to the caller what went wrong, so compare the messages. require.Equal(t, wantMessages, actualMessages) @@ -75,6 +89,6 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL for i := range len(wantJsonAuditLogs) { // compare each item individually so we know which message it is require.Equal(t, wantJsonAuditLogs[i], actualJsonAuditLogs[i], - "audit log for message %q does not match", wantJsonAuditLogs[i]["message"]) + "audit event for message %q does not match", wantJsonAuditLogs[i]["message"]) } } diff --git a/internal/testutil/oidctestutil/expected_upstream_state_param.go b/internal/testutil/oidctestutil/expected_upstream_state_param.go index 72ff6398d..1518f466f 100644 --- a/internal/testutil/oidctestutil/expected_upstream_state_param.go +++ b/internal/testutil/oidctestutil/expected_upstream_state_param.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + "go.pinniped.dev/internal/federationdomain/stateparam" ) // ExpectedUpstreamStateParamFormat is a separate type from the production code to ensure that the state @@ -28,10 +29,10 @@ type ExpectedUpstreamStateParamFormat struct { type UpstreamStateParamBuilder ExpectedUpstreamStateParamFormat -func (b *UpstreamStateParamBuilder) Build(t *testing.T, stateEncoder *securecookie.SecureCookie) string { +func (b *UpstreamStateParamBuilder) Build(t *testing.T, stateEncoder *securecookie.SecureCookie) stateparam.Encoded { state, err := stateEncoder.Encode("s", b) require.NoError(t, err) - return state + return stateparam.Encoded(state) } func (b *UpstreamStateParamBuilder) WithAuthorizeRequestParams(params string) *UpstreamStateParamBuilder { From d729c82f84c28cc64c1472cf3c87101e611af44e Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 08:45:15 -0500 Subject: [PATCH 07/71] fix lint --- .../endpoints/login/login_handler_test.go | 4 ++-- internal/testutil/log_lines.go | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 3e761d442..64506ccb7 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -388,7 +388,7 @@ func TestLoginEndpoint(t *testing.T) { ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, stateparam.Encoded(tt.wantEncodedState), encodedState) + require.Equal(t, tt.wantEncodedState, encodedState) require.Equal(t, tt.wantDecodedState, decodedState) if tt.getHandlerErr == nil { _, err := w.Write([]byte(happyGetResult)) @@ -405,7 +405,7 @@ func TestLoginEndpoint(t *testing.T) { ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, stateparam.Encoded(tt.wantEncodedState), encodedState) + require.Equal(t, tt.wantEncodedState, encodedState) require.Equal(t, tt.wantDecodedState, decodedState) if tt.postHandlerErr == nil { _, err := w.Write([]byte(happyPostResult)) diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 7f1cd7600..7ada0a320 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -41,8 +41,8 @@ func WantAuditLog(message string, params map[string]any, auditID string) WantedA func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { t.Helper() - var wantJsonAuditLogs []map[string]any - var wantMessages []string + wantJsonAuditLogs := make([]map[string]any, 0) + wantMessages := make([]string, 0) for _, wantAuditLog := range wantAuditLogs { wantJsonAuditLog := make(map[string]any) wantJsonAuditLog["level"] = "info" @@ -56,8 +56,8 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL wantJsonAuditLogs = append(wantJsonAuditLogs, wantJsonAuditLog) } - var actualJsonAuditLogs []map[string]any - var actualMessages []string + actualJsonAuditLogs := make([]map[string]any, 0) + actualMessages := make([]string, 0) actualAuditLogs := strings.Split(actualAuditLogsOneLiner, "\n") require.GreaterOrEqual(t, len(actualAuditLogs), 2) actualAuditLogs = actualAuditLogs[:len(actualAuditLogs)-1] // trim off the last "" @@ -78,15 +78,12 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL actualMessages = append(actualMessages, actualMessage) } - // TODO: remove this - t.Logf("LAST AUDIT EVENT: %s", actualAuditLogs[len(actualAuditLogs)-1]) - // We should check array indices first so that we don't exceed any boundaries. // But we also want to be sure to indicate to the caller what went wrong, so compare the messages. require.Equal(t, wantMessages, actualMessages) // We can expect the audit logs to be ordered deterministically. - for i := range len(wantJsonAuditLogs) { + for i := range wantJsonAuditLogs { // compare each item individually so we know which message it is require.Equal(t, wantJsonAuditLogs[i], actualJsonAuditLogs[i], "audit event for message %q does not match", wantJsonAuditLogs[i]["message"]) From a67af9455b4fa589d9adeb4ab0390f2aa78f145a Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 08:48:04 -0500 Subject: [PATCH 08/71] Refactor: don't copy the loop variable in test loops --- .../endpoints/login/login_handler_test.go | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 64506ccb7..cf187192c 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -369,14 +369,12 @@ func TestLoginEndpoint(t *testing.T) { } for _, test := range tests { - tt := test - - t.Run(tt.name, func(t *testing.T) { + t.Run(test.name, func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(tt.method, tt.path, nil) - if tt.csrfCookie != "" { - req.Header.Set("Cookie", tt.csrfCookie) + req := httptest.NewRequest(test.method, test.path, nil) + if test.csrfCookie != "" { + req.Header.Set("Cookie", test.csrfCookie) } rsp := httptest.NewRecorder() @@ -388,13 +386,13 @@ func TestLoginEndpoint(t *testing.T) { ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, tt.wantEncodedState, encodedState) - require.Equal(t, tt.wantDecodedState, decodedState) - if tt.getHandlerErr == nil { + require.Equal(t, test.wantEncodedState, encodedState) + require.Equal(t, test.wantDecodedState, decodedState) + if test.getHandlerErr == nil { _, err := w.Write([]byte(happyGetResult)) require.NoError(t, err) } - return tt.getHandlerErr + return test.getHandlerErr } testPostHandler := func( @@ -405,28 +403,28 @@ func TestLoginEndpoint(t *testing.T) { ) error { require.Equal(t, req, r) require.Equal(t, rsp, w) - require.Equal(t, tt.wantEncodedState, encodedState) - require.Equal(t, tt.wantDecodedState, decodedState) - if tt.postHandlerErr == nil { + require.Equal(t, test.wantEncodedState, encodedState) + require.Equal(t, test.wantDecodedState, decodedState) + if test.postHandlerErr == nil { _, err := w.Write([]byte(happyPostResult)) require.NoError(t, err) } - return tt.postHandlerErr + return test.postHandlerErr } subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, plog.New()) subject.ServeHTTP(rsp, req) - if tt.method == http.MethodPost { + if test.method == http.MethodPost { testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp) } else { testutil.RequireSecurityHeadersWithLoginPageCSPs(t, rsp) } - require.Equal(t, tt.wantStatus, rsp.Code) - testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) - require.Equal(t, tt.wantBody, rsp.Body.String()) + require.Equal(t, test.wantStatus, rsp.Code) + testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) + require.Equal(t, test.wantBody, rsp.Body.String()) }) } } From dd42f35db0d0eac671ce23411242ded16629b8c7 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 09:18:47 -0500 Subject: [PATCH 09/71] plog.TestLogger returns a buffer that holds the logs # Conflicts: # internal/controller/apicerts/certs_expirer_test.go # internal/plog/plog_test.go # internal/plog/testing.go # pkg/oidcclient/login_test.go --- .../github_upstream_watcher_test.go | 2 +- .../endpoints/auth/auth_handler_test.go | 12 +++++------- internal/plog/plog_test.go | 11 ++++++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go index 7f734d7ea..eb4325c9c 100644 --- a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go @@ -2555,7 +2555,7 @@ func TestController(t *testing.T) { require.Len(t, actualIDP.Status.Conditions, countExpectedConditions) require.Equal(t, tt.wantResultingUpstreams[i], *actualIDP) } - testutil.RequireLogLines(t, tt.wantLogs, &log) + testutil.RequireLogLines(t, tt.wantLogs, log) // This needs to happen after the expected condition LastTransitionTime has been updated. wantActions := make([]coretesting.Action, 3+len(tt.wantResultingUpstreams)) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index e5db3cdc3..d84546835 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -3941,8 +3941,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if len(test.wantDownstreamAdditionalClaims) > 0 { require.True(t, oidcIDPsCount > 0, "wantDownstreamAdditionalClaims requires at least one OIDC IDP") } - var auditLog bytes.Buffer - auditLogger := plog.TestLogger(t, &auditLog) + auditLogger, auditLog := plog.TestLogger(t) subject := NewHandler( downstreamIssuer, idps, @@ -3951,7 +3950,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo test.stateEncoder, test.cookieEncoder, auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) }) } @@ -3969,8 +3968,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient) oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient) idpLister := test.idps.BuildFederationDomainIdentityProvidersListerFinder() - var auditLog bytes.Buffer - auditLogger := plog.TestLogger(t, &auditLog) + auditLogger, auditLog := plog.TestLogger(t) subject := NewHandler( downstreamIssuer, idpLister, @@ -3980,7 +3978,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) // Call the idpLister's setter to change the upstream IDP settings. newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). @@ -4023,7 +4021,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo // modified expectations. This should ensure that the implementation is using the in-memory cache // of upstream IDP settings appropriately in terms of always getting the values from the cache // on every request. - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, &auditLog) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) }) } diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index 2e30ea3e3..c2df44c7f 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -4,6 +4,7 @@ package plog import ( + "bytes" "fmt" "runtime" "strings" @@ -355,14 +356,14 @@ func TestPlog(t *testing.T) { `, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { t.Parallel() - subjectLogger, log := TestLogger(t) - tt.run(subjectLogger) + testLogger, log := TestLogger(t) + test.run(testLogger) - require.Equal(t, strings.TrimSpace(tt.want), strings.TrimSpace(log.String())) + require.Equal(t, strings.TrimSpace(test.want), strings.TrimSpace(log.String())) }) } } From d020de4b3d9d5b13e7a8cc8906d46859d5f0766e Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 1 Nov 2024 12:54:49 -0700 Subject: [PATCH 10/71] update fips reference doc --- site/content/docs/reference/fips.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/site/content/docs/reference/fips.md b/site/content/docs/reference/fips.md index e3391b182..a10a3db2a 100644 --- a/site/content/docs/reference/fips.md +++ b/site/content/docs/reference/fips.md @@ -13,15 +13,14 @@ By default, the Pinniped supervisor and concierge use ciphers that are not supported by FIPS 140-2. If you are deploying Pinniped in an environment with FIPS compliance requirements, you will have to build the binaries yourself using the `fips_strict` build tag and Golang's -`go-boringcrypto` fork. +`GOEXPERIMENT=boringcrypto` compiler option. The Pinniped team provides an [example Dockerfile](https://github.com/vmware-tanzu/pinniped/blob/main/hack/Dockerfile_fips) demonstrating how you can build Pinniped images in a FIPS compatible way. -However, we do not provide official support for FIPS configuration, and we may not -respond to GitHub issues opened related to FIPS support. +However, we do not provide official support for FIPS configuration. We provide this for informational purposes only. -To build Pinniped use our example fips Dockerfile, you can run: +To build Pinniped use our example FIPS Dockerfile, you can run: ```bash $ git clone git@github.com:vmware-tanzu/pinniped.git $ cd pinniped From 4df043a91c6d9931a002929d06c8678ebc5b2760 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 1 Nov 2024 14:12:13 -0700 Subject: [PATCH 11/71] document audit logging --- site/content/docs/reference/audit-logging.md | 120 ++++++++++++++++++ .../docs/reference/code-walkthrough.md | 2 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 site/content/docs/reference/audit-logging.md diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md new file mode 100644 index 000000000..1fd133a47 --- /dev/null +++ b/site/content/docs/reference/audit-logging.md @@ -0,0 +1,120 @@ +--- +title: Supervisor and Concierge Audit Logging +description: Reference for audit log statements in Pinniped pod logs +cascade: + layout: docs +menu: + docs: + name: Audit Logging + weight: 40 + parent: reference +--- + +The Pinniped Supervisor and Pinniped Concierge components provide audit logging capabilities +to help you meet your security and compliance standards. + +The configuration of the Pinniped Supervisor and Pinniped Concierge is managed by Kubernetes +custom resources and aggregated APIs. These resources and APIs are protected by the +[standard Kubernetes authorization controls](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) +and audited by the +[standard Kubernetes audit logging](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/) +capabilities. + +In addition, there are several APIs exposed to all end-users by Pinniped to aid with end-user authentication. +For these APIs, Pinniped offers additional audit logging capabilities. These additional audit logs appear in +the pod logs of the Supervisor and Concierge pods. Each line of the pod logs is a JSON object. +Although these audit events are interleaved with other pod log messages, they are identifiable by always +having an `"auditEvent"=true` key/value pair. + +## APIs that can emit Pinniped audit events to the pod logs + +Both the Supervisor and the Concierge offer custom resource definitions (CRDs) for configuration, +which are protected by Kubernetes RBAC and typically only available for administrators to use. +End-users typically cannot access these APIs, and they are not part of the authentication flows for end-users. +These resources are audited only by the standard Kubernetes audit logging. + +The Pinniped Supervisor offers one additional API for administrators, which is an aggregated API called +`OIDCClientSecretRequest` to create client secrets for `OIDCClient` resources. +End-users typically cannot access this API (protected by Kubernetes RBAC), and it is not part of the authentication +flows for end-users. This API is audited by both the standard Kubernetes audit logging and may emit Pinniped audit events. + +The Pinniped Concierge offers two public APIs for end-user authentication, which are both aggregated APIs. +These will be audited by both the standard Kubernetes audit logging and may emit Pinniped audit events. +- `TokenCredendtialRequest`: This API authenticates a user and returns a temporary cluster credential for that user. +- `WhoAmIRequest`: This API returns the username and group memberships of the user who invokes it. + +The Pinniped Supervisor offers several public APIs for end-user authentication for each configured FederationDomain. +These are not aggregated APIs, so they are not audited by the standard Kubernetes audit logging. +These will emit Pinniped audit events. Each request to these APIs may emit several audit events. +These APIs include: +- `/.well-known/openid-configuration` is the standard OIDC discovery endpoint, which can be used to discover all the other endpoints listed here. +- `/jwks.json` is the standard OIDC JWKS discovery endpoint. +- `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. +- `/oauth2/authorize` is the standard OIDC authorize endpoint. +- `/oauth2/token` is the standard OIDC token endpoint. + The token endpoint can handle the standard OIDC `authorization_code` and `refresh_token` grant types, and has also been + extended to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to + reduce the applicable scope (technically, the `aud` claim) of ID tokens. +- `/callback` is a special endpoint that is used as the redirect URL when performing an OAuth 2.0 or OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider or GitHubIdentityProvider custom resource. +- `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. + +## Structure of an audit event + +Every line in a Supervisor or Concierge pod log is a JSON object. Only the lines that include the +key/value pair `"auditEvent": true` are audit events. Other lines are for errors, warnings, and +debugging information. + +Every line contains the following keys, and audit event lines also contain these common keys/values: + +- `timestamp`, whose value is in UTC time, e.g. `2024-07-10T20:03:26.164470Z` +- `level`, which for an audit event will always have the value `info` +- `message`, which for audit events is effectively the event type, whose + value will always be one the messages declared as an enum in `audit_events.go`, + which is effectively a catalog of all possible audit event types +- `caller`, which is line of Go code which caused the log +- `stacktrace`, which is only included when the global log level is configured to `trace` or `all`, + in which case the value shows a full Go stacktrace for the caller + +Every audit event log line may also have the following keys/values: + +- When applicable, logs lines have an `auditID` which is a unique ID for every HTTP request, to allow multiple + lines of audit events to be correlated when they came from a single HTTP request. This `auditID` is also returned + to the client as an HTTP response header to allow for correlation between the request as observed by the client + and the logs as observed by the administrator. For the aggregated APIs, the `auditID` can also be used to + correlate the request to the same request as shown in the standard Kubernetes audit log, where it will have + the same `auditID`. +- When applicable, logs lines have a `sessionID` which is the unique ID of a stored Pinniped Supervisor user session, + to allow audit events to be correlated which relate to a single session even when they are caused by different + requests or controllers. The same `sessionID` can help you observe all the actions performed during a single user's + session across multiple HTTP requests that make up a fresh login, token exchanges, multiple session refreshes, and + session garbage collection. +- When applicable, logs lines have an `authorizeID` which is a unique ID to allow audit events to be correlated + across some of the browser redirects which relate to a single login attempt by an end-user. This is only applicable + to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. +- Each audit event may also has more key/value pairs specific to the event's type. + +## Configuration options for audit events + +Audit events are enabled by default. There are two configuration options available: + +1. By default, usernames and group names are not included in the audit events. This is because these names may + include personally identifiable information (PII) which you may wish to avoid sending to your pod logs. + However, authentication audit logs can be more useful when this information is included. +2. By default, some endpoints that are internal to the Kubernetes cluster are not audited in the pod logs. + These include, for example, a `healthz` endpoint that is used for pod liveness and readiness probes, + some discovery endpoints called by the Kubernetes API server to discover the endpoints made available by + the Pinniped pods, and other similar endpoints. These are typically not available to end-users and therefore + not always as interesting for authentication auditing. + +Both of these can be optionally enabled in the ConfigMaps which hold the pod startup settings for the Supervisor +and Concierge deployments. When these ConfigMaps are changed, the corresponding Supervisor or Concierge pods must +be restarted for the new settings to be picked up by the pods. + +## Exporting Pinniped audit events off-cluster + +There are several tools to help cluster administrators export pod logs off-cluster for safe keeping. Because Pinniped +audit events appear in the pod logs, they will be exported along with the rest of the lines in the pod logs. +Popular tools, like [Fluentbit](https://fluentbit.io), allow configuration options that could let you +export only the audit event lines, or export the audit event lines separately from the other log lines. +This can be achieved by configuring Fluentbit `FILTER`s to evaluate each Supervisor or Concierge pod log line +based on the presence or absence of the `"auditEvent"=true` key/value pair. diff --git a/site/content/docs/reference/code-walkthrough.md b/site/content/docs/reference/code-walkthrough.md index 3218dfbaf..ed2d8c0d2 100644 --- a/site/content/docs/reference/code-walkthrough.md +++ b/site/content/docs/reference/code-walkthrough.md @@ -166,7 +166,7 @@ as aggregated API endpoints, which makes them appear to a client almost as if th as that user. It is in [internal/registry/credentialrequest/rest.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/registry/credentialrequest/rest.go). -- `WhoAmI` will return basic details about the currently authenticated user. +- `WhoAmIRequest` will return basic details about the currently authenticated user. It is in [internal/registry/whoamirequest/rest.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/registry/whoamirequest/rest.go). The Concierge may also run an impersonation proxy service. This is not an aggregated API endpoint, so it needs to be From dd56f2b47f253e6feb8176373637d23d850edf0c Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 12:25:55 -0500 Subject: [PATCH 12/71] Add audit event tests for callback_handler --- .../endpoints/auth/auth_handler_test.go | 114 ++++++++--------- .../callback/callback_handler_test.go | 116 +++++++++++++++++- internal/testutil/log_lines.go | 12 +- 3 files changed, 175 insertions(+), 67 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index d84546835..5a1dd4e35 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -35,7 +35,6 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" - "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" @@ -660,10 +659,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) - wantAuditLog := func(message string, params map[string]any) testutil.WantedAuditLog { - return testutil.WantAuditLog(message, params, "some-audit-id") - } - type testCase struct { name string @@ -730,20 +725,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -768,20 +763,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -805,20 +800,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", "resourceUID": "github-resource-uid", "type": "github", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -843,20 +838,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", "resourceUID": "github-resource-uid", "type": "github", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -880,20 +875,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", "resourceUID": "ldap-resource-uid", "type": "ldap", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -918,20 +913,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -957,11 +952,11 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), } @@ -987,20 +982,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", "type": "oidc", }), - wantAuditLog("Upstream Authorize Redirect", map[string]any{ + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), } @@ -1101,20 +1096,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", "resourceUID": "some-password-granting-resource-uid", "type": "oidc", }), - wantAuditLog("Identity From Upstream IDP", map[string]any{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-password-granting-oidc-idp", "upstreamIDPResourceName": "some-password-granting-oidc-idp", "upstreamIDPResourceUID": "some-password-granting-resource-uid", @@ -1122,7 +1117,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamUsername": "test-oidc-pinniped-username", "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, }), - wantAuditLog("Session Started", map[string]any{ + testutil.WantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "test-oidc-pinniped-username", "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, @@ -1176,20 +1171,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", "resourceUID": "some-password-granting-resource-uid", "type": "oidc", }), - wantAuditLog("Identity From Upstream IDP", map[string]any{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-password-granting-oidc-idp", "upstreamIDPResourceName": "some-password-granting-oidc-idp", "upstreamIDPResourceUID": "some-password-granting-resource-uid", @@ -1197,7 +1192,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamUsername": "test-oidc-pinniped-username", "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, }), - wantAuditLog("Authentication Rejected By Transforms", map[string]any{ + testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", }), } @@ -1287,20 +1282,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", "resourceUID": "ldap-resource-uid", "type": "ldap", }), - wantAuditLog("Identity From Upstream IDP", map[string]any{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-ldap-idp", "upstreamIDPResourceName": "some-ldap-idp", "upstreamIDPResourceUID": "ldap-resource-uid", @@ -1308,7 +1303,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamUsername": "some-ldap-username-from-authenticator", "upstreamGroups": []any{"group1", "group2", "group3"}, }), - wantAuditLog("Session Started", map[string]any{ + testutil.WantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "some-ldap-username-from-authenticator", "groups": []any{"group1", "group2", "group3"}, @@ -1347,20 +1342,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo ), wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": true, "Pinniped-Password": true, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", "resourceUID": "ldap-resource-uid", "type": "ldap", }), - wantAuditLog("Identity From Upstream IDP", map[string]any{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-ldap-idp", "upstreamIDPResourceName": "some-ldap-idp", "upstreamIDPResourceUID": "ldap-resource-uid", @@ -1368,7 +1363,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamUsername": "some-ldap-username-from-authenticator", "upstreamGroups": []any{"group1", "group2", "group3"}, }), - wantAuditLog("Session Started", map[string]any{ + testutil.WantAuditLog("Session Started", map[string]any{ "sessionID": sessionID, "username": "username_prefix:some-ldap-username-from-authenticator", "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, @@ -1719,14 +1714,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - wantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ "Pinniped-Username": false, "Pinniped-Password": false, }), - wantAuditLog("HTTP Request Parameters", map[string]any{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, }), - wantAuditLog("Using Upstream IDP", map[string]any{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", "resourceUID": "oidc-resource-uid", @@ -3810,9 +3805,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo } rsp := httptest.NewRecorder() - req, _ = requestlogger.NewRequestWithAuditID(req, func() string { - return "some-audit-id" - }) subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 1e4189aa2..5950f798b 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -247,6 +247,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamAdditionalClaims map[string]any wantOIDCAuthcodeExchangeCall *expectedOIDCAuthcodeExchange wantGitHubAuthcodeExchangeCall *expectedGitHubAuthcodeExchange + wantAuditLogs func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog }{ { name: "OIDC: GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", @@ -278,6 +279,29 @@ func TestCallbackEndpoint(t *testing.T) { performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "upstream-oidc-idp-name", + "upstreamIDPType": "oidc", + "upstreamIDPResourceName": "upstream-oidc-idp-name", + "upstreamIDPResourceUID": "upstream-oidc-resource-uid", + "upstreamUsername": "test-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "test-pinniped-username", + "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "subject": "https://my-upstream-issuer.com?idpName=upstream-oidc-idp-name&sub=abc123-some+guid", + "additionalClaims": map[string]any{}, // json: {} + "warnings": []any{}, // json: [] + }), + } + }, }, { name: "GitHub: GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form", @@ -309,6 +333,29 @@ func TestCallbackEndpoint(t *testing.T) { performedByUpstreamName: happyGithubIDPName, args: happyGitHubUpstreamExchangeAuthcodeArgs, }, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "upstream-github-idp-name", + "upstreamIDPType": "github", + "upstreamIDPResourceName": "upstream-github-idp-name", + "upstreamIDPResourceUID": "upstream-github-idp-resource-uid", + "upstreamUsername": "some-github-login", + "upstreamGroups": []any{"org1/team1", "org2/team2"}, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "some-github-login", + "groups": []any{"org1/team1", "org2/team2"}, + "subject": "https://github.com?idpName=upstream-github-idp-name&sub=some-github-login", + "additionalClaims": nil, // json: null + "warnings": []any{}, // json: [] + }), + } + }, }, { name: "GET with good state and cookie with additional params", @@ -657,6 +704,13 @@ func TestCallbackEndpoint(t *testing.T) { performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, _ string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "return an error when upstream IDP returned no refresh token and no access token", @@ -1088,6 +1142,9 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, wantBody: "Bad Request: state param not found\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{} + }, }, { name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason", @@ -1720,6 +1777,24 @@ func TestCallbackEndpoint(t *testing.T) { performedByUpstreamName: happyOIDCUpstreamIDPName, args: happyOIDCUpstreamExchangeAuthcodeAndValidateTokenArgs, }, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "upstream-oidc-idp-name", + "upstreamIDPType": "oidc", + "upstreamIDPResourceName": "upstream-oidc-idp-name", + "upstreamIDPResourceUID": "upstream-oidc-resource-uid", + "upstreamUsername": "test-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }), + testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ + "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", + }), + } + }, }, { name: "GitHub: using identity transformations which reject the authentication", @@ -1735,6 +1810,24 @@ func TestCallbackEndpoint(t *testing.T) { performedByUpstreamName: happyGithubIDPName, args: happyGitHubUpstreamExchangeAuthcodeArgs, }, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "upstream-github-idp-name", + "upstreamIDPType": "github", + "upstreamIDPResourceName": "upstream-github-idp-name", + "upstreamIDPResourceUID": "upstream-github-idp-resource-uid", + "upstreamUsername": "some-github-login", + "upstreamGroups": []any{"org1/team1", "org2/team2"}, + }), + testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ + "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", + }), + } + }, }, } @@ -1759,13 +1852,15 @@ func TestCallbackEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + logger, log := plog.TestLogger(t) + subject := NewHandler( test.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI, - plog.New(), + logger, ) reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") @@ -1800,6 +1895,8 @@ func TestCallbackEndpoint(t *testing.T) { require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) + sessionID := "" + switch { // If we want a specific static response body, assert that. case test.wantBody != "": @@ -1807,7 +1904,7 @@ func TestCallbackEndpoint(t *testing.T) { // Else if we want a body that contains a regex-matched auth code, assert that (for "response_mode=form_post"). case test.wantBodyFormResponseRegexp != "": - _ = oidctestutil.RequireAuthCodeRegexpMatch( + sessionID = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Body.String(), test.wantBodyFormResponseRegexp, @@ -1835,7 +1932,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.wantRedirectLocationRegexp != "" { require.Len(t, rsp.Header().Values("Location"), 1) - _ = oidctestutil.RequireAuthCodeRegexpMatch( + sessionID = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Header().Get("Location"), test.wantRedirectLocationRegexp, @@ -1856,6 +1953,19 @@ func TestCallbackEndpoint(t *testing.T) { test.wantDownstreamAdditionalClaims, ) } + + if test.wantAuditLogs != nil { + var encodedStateParam stateparam.Encoded + if test.path != "" { + var path *url.URL + path, err = url.Parse(test.path) + require.NoError(t, err) + encodedStateParam = stateparam.Encoded(path.Query().Get("state")) + } + + wantAuditLogs := test.wantAuditLogs(encodedStateParam, sessionID) + testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) + } }) } } diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 7ada0a320..02ce4f243 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -27,13 +27,13 @@ type WantedAuditLog struct { Params map[string]any } -func WantAuditLog(message string, params map[string]any, auditID string) WantedAuditLog { +func WantAuditLog(message string, params map[string]any, auditID ...string) WantedAuditLog { result := WantedAuditLog{ Message: message, Params: params, } - if auditID != "" { - result.Params["auditID"] = auditID + if len(auditID) > 0 { + result.Params["auditID"] = auditID[0] } return result } @@ -41,6 +41,12 @@ func WantAuditLog(message string, params map[string]any, auditID string) WantedA func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { t.Helper() + // There are tests that verify that no audit events were emitted + if len(wantAuditLogs) == 0 { + require.Empty(t, actualAuditLogsOneLiner, "no audit events were expected, but some were found") + return + } + wantJsonAuditLogs := make([]map[string]any, 0) wantMessages := make([]string, 0) for _, wantAuditLog := range wantAuditLogs { From 9994e033b201967e8d6b776e9b489732d7c66f44 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 13:52:31 -0500 Subject: [PATCH 13/71] Add audit event tests for login_handler --- .../endpoints/login/login_handler_test.go | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index cf187192c..5d3aceb40 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -122,6 +122,7 @@ func TestLoginEndpoint(t *testing.T) { wantBody string wantEncodedState stateparam.Encoded wantDecodedState *oidc.UpstreamStateParamData + wantAuditLogs func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog }{ { name: "PUT method is invalid", @@ -131,6 +132,9 @@ func TestLoginEndpoint(t *testing.T) { wantStatus: http.StatusMethodNotAllowed, wantContentType: htmlContentType, wantBody: "Method Not Allowed: PUT (try GET or POST)\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{} + }, }, { name: "PATCH method is invalid", @@ -194,6 +198,9 @@ func TestLoginEndpoint(t *testing.T) { wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, wantBody: "Bad Request: state param not found\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{} + }, }, { name: "state param was not included on POST request", @@ -332,6 +339,13 @@ func TestLoginEndpoint(t *testing.T) { wantBody: happyGetResult, wantEncodedState: happyState, wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "happy POST request for LDAP upstream", @@ -343,6 +357,13 @@ func TestLoginEndpoint(t *testing.T) { wantBody: happyPostResult, wantEncodedState: happyState, wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "happy GET request for ActiveDirectory upstream", @@ -354,6 +375,13 @@ func TestLoginEndpoint(t *testing.T) { wantBody: happyGetResult, wantEncodedState: happyActiveDirectoryState, wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, { name: "happy POST request for ActiveDirectory upstream", @@ -365,6 +393,13 @@ func TestLoginEndpoint(t *testing.T) { wantBody: happyPostResult, wantEncodedState: happyActiveDirectoryState, wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, }, } @@ -412,7 +447,9 @@ func TestLoginEndpoint(t *testing.T) { return test.postHandlerErr } - subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, plog.New()) + logger, log := plog.TestLogger(t) + + subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, logger) subject.ServeHTTP(rsp, req) @@ -425,6 +462,19 @@ func TestLoginEndpoint(t *testing.T) { require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) require.Equal(t, test.wantBody, rsp.Body.String()) + + if test.wantAuditLogs != nil { + var encodedStateParam stateparam.Encoded + if test.path != "" { + var path *url.URL + path, err = url.Parse(test.path) + require.NoError(t, err) + encodedStateParam = stateparam.Encoded(path.Query().Get("state")) + } + + wantAuditLogs := test.wantAuditLogs(encodedStateParam) + testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) + } }) } } From 09ca7920ea8df8a002a2f08663dee051a8431951 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 1 Nov 2024 13:55:29 -0500 Subject: [PATCH 14/71] Extract testutil helper function --- .../endpoints/callback/callback_handler_test.go | 10 +--------- .../endpoints/login/login_handler_test.go | 10 +--------- internal/testutil/log_lines.go | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 5950f798b..19ae26a39 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -1955,15 +1955,7 @@ func TestCallbackEndpoint(t *testing.T) { } if test.wantAuditLogs != nil { - var encodedStateParam stateparam.Encoded - if test.path != "" { - var path *url.URL - path, err = url.Parse(test.path) - require.NoError(t, err) - encodedStateParam = stateparam.Encoded(path.Query().Get("state")) - } - - wantAuditLogs := test.wantAuditLogs(encodedStateParam, sessionID) + wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path), sessionID) testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) } }) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 5d3aceb40..776b93ca5 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -464,15 +464,7 @@ func TestLoginEndpoint(t *testing.T) { require.Equal(t, test.wantBody, rsp.Body.String()) if test.wantAuditLogs != nil { - var encodedStateParam stateparam.Encoded - if test.path != "" { - var path *url.URL - path, err = url.Parse(test.path) - require.NoError(t, err) - encodedStateParam = stateparam.Encoded(path.Query().Get("state")) - } - - wantAuditLogs := test.wantAuditLogs(encodedStateParam) + wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path)) testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) } }) diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 02ce4f243..d60ae0bbf 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -6,10 +6,13 @@ package testutil import ( "bytes" "encoding/json" + "net/url" "strings" "testing" "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/federationdomain/stateparam" ) func RequireLogLines(t *testing.T, wantLogs []string, log *bytes.Buffer) { @@ -38,6 +41,17 @@ func WantAuditLog(message string, params map[string]any, auditID ...string) Want return result } +func GetStateParam(t *testing.T, fullURL string) stateparam.Encoded { + var encodedStateParam stateparam.Encoded + if fullURL != "" { + path, err := url.Parse(fullURL) + require.NoError(t, err) + encodedStateParam = stateparam.Encoded(path.Query().Get("state")) + } + + return encodedStateParam +} + func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { t.Helper() From cf4b29de4b9b5145852dc43861ff1582d3823f38 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 4 Nov 2024 09:28:31 -0600 Subject: [PATCH 15/71] Clarify docs --- .../endpoints/token/token_handler.go | 2 +- site/content/docs/reference/audit-logging.md | 81 ++++++++++++++----- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 089b8043e..cce08e8d9 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -32,7 +32,7 @@ import ( //nolint:gochecknoglobals // please treat this as a readonly const, do not mutate var paramsSafeToLog = sets.New[string]( - // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcde and refresh grants. + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. "grant_type", "client_id", "redirect_uri", "scope", // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index 1fd133a47..fb85f3a70 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -20,7 +20,7 @@ and audited by the [standard Kubernetes audit logging](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/) capabilities. -In addition, there are several APIs exposed to all end-users by Pinniped to aid with end-user authentication. +In addition, Pinniped exposes several APIs to all end-users to provide end-user authentication. For these APIs, Pinniped offers additional audit logging capabilities. These additional audit logs appear in the pod logs of the Supervisor and Concierge pods. Each line of the pod logs is a JSON object. Although these audit events are interleaved with other pod log messages, they are identifiable by always @@ -31,7 +31,7 @@ having an `"auditEvent"=true` key/value pair. Both the Supervisor and the Concierge offer custom resource definitions (CRDs) for configuration, which are protected by Kubernetes RBAC and typically only available for administrators to use. End-users typically cannot access these APIs, and they are not part of the authentication flows for end-users. -These resources are audited only by the standard Kubernetes audit logging. +Changes to these resources are audited by the standard Kubernetes audit logging. The Pinniped Supervisor offers one additional API for administrators, which is an aggregated API called `OIDCClientSecretRequest` to create client secrets for `OIDCClient` resources. @@ -43,7 +43,7 @@ These will be audited by both the standard Kubernetes audit logging and may emit - `TokenCredendtialRequest`: This API authenticates a user and returns a temporary cluster credential for that user. - `WhoAmIRequest`: This API returns the username and group memberships of the user who invokes it. -The Pinniped Supervisor offers several public APIs for end-user authentication for each configured FederationDomain. +The Pinniped Supervisor offers several public APIs for end-user authentication for each configured `FederationDomain`. These are not aggregated APIs, so they are not audited by the standard Kubernetes audit logging. These will emit Pinniped audit events. Each request to these APIs may emit several audit events. These APIs include: @@ -55,47 +55,48 @@ These APIs include: The token endpoint can handle the standard OIDC `authorization_code` and `refresh_token` grant types, and has also been extended to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to reduce the applicable scope (technically, the `aud` claim) of ID tokens. -- `/callback` is a special endpoint that is used as the redirect URL when performing an OAuth 2.0 or OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider or GitHubIdentityProvider custom resource. +- `/callback` is a special endpoint that is used as the redirect URL when performing an OAuth 2.0 or OIDC authcode flow against an upstream OIDC identity provider as configured by an `OIDCIdentityProvider` or `GitHubIdentityProvider` custom resource. - `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. ## Structure of an audit event -Every line in a Supervisor or Concierge pod log is a JSON object. Only the lines that include the +Every log line in a Supervisor or Concierge pod log is a JSON object. Only those log lines that include the key/value pair `"auditEvent": true` are audit events. Other lines are for errors, warnings, and debugging information. -Every line contains the following keys, and audit event lines also contain these common keys/values: +Every audit log contains the following keys, and audit event lines also contain these common keys/values: - `timestamp`, whose value is in UTC time, e.g. `2024-07-10T20:03:26.164470Z` - `level`, which for an audit event will always have the value `info` -- `message`, which for audit events is effectively the event type, whose - value will always be one the messages declared as an enum in `audit_events.go`, +- `message`, which for audit events is effectively the audit event type, whose + value will always be one of the messages declared as an enum in `audit_events.go`, which is effectively a catalog of all possible audit event types -- `caller`, which is line of Go code which caused the log +- `caller`, which is the line of Go code which caused the log - `stacktrace`, which is only included when the global log level is configured to `trace` or `all`, in which case the value shows a full Go stacktrace for the caller -Every audit event log line may also have the following keys/values: +Every audit event log line may also have the following keys/values, which are specifically designed to correlate +audit logs both forwards and backwards in time. The values for these keys are opaque and only used for correlation. -- When applicable, logs lines have an `auditID` which is a unique ID for every HTTP request, to allow multiple +- When applicable, audit logs have an `auditID` which is a unique ID for every HTTP request, to allow multiple lines of audit events to be correlated when they came from a single HTTP request. This `auditID` is also returned to the client as an HTTP response header to allow for correlation between the request as observed by the client - and the logs as observed by the administrator. For the aggregated APIs, the `auditID` can also be used to - correlate the request to the same request as shown in the standard Kubernetes audit log, where it will have - the same `auditID`. -- When applicable, logs lines have a `sessionID` which is the unique ID of a stored Pinniped Supervisor user session, + and the logs as observed by the administrator. For aggregated APIs only, the `auditID` can also be used to + correlate Pinniped audit events with Kubernetes audit logs, which will use the same `auditID`. +- When applicable, audit logs have a `sessionID` which is the unique ID of a stored Pinniped Supervisor user session, to allow audit events to be correlated which relate to a single session even when they are caused by different requests or controllers. The same `sessionID` can help you observe all the actions performed during a single user's session across multiple HTTP requests that make up a fresh login, token exchanges, multiple session refreshes, and session garbage collection. -- When applicable, logs lines have an `authorizeID` which is a unique ID to allow audit events to be correlated +- When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated across some of the browser redirects which relate to a single login attempt by an end-user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. -- Each audit event may also has more key/value pairs specific to the event's type. + +Each audit event may also have more key/value pairs specific to the event's type. ## Configuration options for audit events -Audit events are enabled by default. There are two configuration options available: +Audit events are always enabled. There are two configuration options available: 1. By default, usernames and group names are not included in the audit events. This is because these names may include personally identifiable information (PII) which you may wish to avoid sending to your pod logs. @@ -110,6 +111,16 @@ Both of these can be optionally enabled in the ConfigMaps which hold the pod sta and Concierge deployments. When these ConfigMaps are changed, the corresponding Supervisor or Concierge pods must be restarted for the new settings to be picked up by the pods. +TODO: Document this configuration, probably something like so: + +```yaml +audit: + show_personally_identifiable_information: enabled + audit_internal_endpoints: enabled +``` + +# + ## Exporting Pinniped audit events off-cluster There are several tools to help cluster administrators export pod logs off-cluster for safe keeping. Because Pinniped @@ -118,3 +129,37 @@ Popular tools, like [Fluentbit](https://fluentbit.io), allow configuration optio export only the audit event lines, or export the audit event lines separately from the other log lines. This can be achieved by configuring Fluentbit `FILTER`s to evaluate each Supervisor or Concierge pod log line based on the presence or absence of the `"auditEvent"=true` key/value pair. + + +## TODO: Show audit events for a sample flow, in this case an LDAP browser flow + +```json lines +{"message":"HTTP Request Received","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"proto":"HTTP/2.0","method":"GET","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/authorize","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} +{"message":"HTTP Request Custom Headers Used","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false} +{"message":"HTTP Request Parameters","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"params":"access_type=offline&client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=My+LDAP+IDP+%F0%9F%9A%80&redirect_uri=http%3A%2F%2F127.0.0.1%3A52377%2Fcallback&response_mode=form_post&response_type=code&scope=groups+offline_access+openid+pinniped%3Arequest-audience+username&state=redacted"} +{"message":"Using Upstream IDP","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"displayName":"My LDAP IDP 🚀","resourceName":"my-ldap-provider","resourceUID":"e8006e7c-91d0-4aa5-b655-844fa2d4aaa4","type":"ldap"} +{"message":"Upstream Authorize Redirect","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} +{"message":"HTTP Request Completed","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"path":"/some/path/oauth2/authorize","latency":"510.279µs","responseStatus":303,"location":"https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/login?state=redacted"} +{"message":"HTTP Request Received","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"proto":"HTTP/2.0","method":"GET","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/login","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} +{"message":"AuthorizeID From Parameters","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} +{"message":"HTTP Request Completed","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"path":"/some/path/login","latency":"786.974µs","responseStatus":200,"location":"no location header"} +{"message":"HTTP Request Received","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/login","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} +{"message":"AuthorizeID From Parameters","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} +{"message":"Identity From Upstream IDP","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"upstreamIDPDisplayName":"My LDAP IDP 🚀","upstreamIDPType":"ldap","upstreamIDPResourceName":"my-ldap-provider","upstreamIDPResourceUID":"e8006e7c-91d0-4aa5-b655-844fa2d4aaa4","upstreamUsername":"pinny.ldap@example.com","upstreamGroups":["ball-game-players","seals"]} +{"message":"Session Started","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"username":"ldap:pinny.ldap@example.com","groups":["ldap:ball-admins","ldap:ball-game-players"],"subject":"ldaps://ldap.tools.svc.cluster.local?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=My+LDAP+IDP+%F0%9F%9A%80&sub=MTAwMA","additionalClaims":null,"warnings":[]} +{"message":"HTTP Request Completed","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"path":"/some/path/login","latency":"47.139942ms","responseStatus":200,"location":"no location header"} +{"message":"HTTP Request Received","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:41922"} +{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"params":"code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%3A52377%2Fcallback"} +{"message":"HTTP Request Completed","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"path":"/some/path/oauth2/token","latency":"207.835054ms","responseStatus":200,"location":"no location header"} +{"message":"HTTP Request Received","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:41922"} +{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"params":"audience=my-workload-cluster-3b4294dd&client_id=pinniped-cli&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt&subject_token=redacted&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token"} +{"message":"HTTP Request Completed","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"path":"/some/path/oauth2/token","latency":"183.118075ms","responseStatus":200,"location":"no location header"} +{"message":"HTTP Request Received","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:50346"} +{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"params":"grant_type=refresh_token&refresh_token=redacted"} +{"message":"Identity Refreshed From Upstream IDP","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"upstreamUsername":"pinny.ldap@example.com","upstreamGroups":["ball-game-players","seals"]} +{"message":"Session Refreshed","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"username":"ldap:pinny.ldap@example.com","groups":["ldap:ball-admins","ldap:ball-game-players"],"subject":"ldaps://ldap.tools.svc.cluster.local?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=My+LDAP+IDP+%F0%9F%9A%80&sub=MTAwMA"} +{"message":"HTTP Request Completed","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"path":"/some/path/oauth2/token","latency":"41.358432ms","responseStatus":200,"location":"no location header"} +{"message":"HTTP Request Received","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:50346"} +{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"params":"audience=my-workload-cluster-3b4294dd&client_id=pinniped-cli&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt&subject_token=redacted&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token"} +{"message":"HTTP Request Completed","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"path":"/some/path/oauth2/token","latency":"2.993264ms","responseStatus":200,"location":"no location header"} +``` From 369316556a50b664b04a72d1d00765f8f20b5110 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 4 Nov 2024 12:15:20 -0600 Subject: [PATCH 16/71] Add configuration to audit internal endpoints and backfill unit tests --- internal/config/supervisor/types.go | 11 +- .../endpoints/auth/auth_handler.go | 31 +-- .../endpoints/token/token_handler.go | 23 +- .../endpointsmanager/manager.go | 8 +- .../requestlogger/request_logger.go | 37 ++- .../requestlogger/request_logger_test.go | 225 ++++++++++++++++++ internal/mocks/mockresponsewriter/generate.go | 6 + .../mockresponsewriter/mockresponsewriter.go | 86 +++++++ internal/supervisor/server/server.go | 1 + site/content/docs/reference/audit-logging.md | 2 +- 10 files changed, 392 insertions(+), 38 deletions(-) create mode 100644 internal/federationdomain/requestlogger/request_logger_test.go create mode 100644 internal/mocks/mockresponsewriter/generate.go create mode 100644 internal/mocks/mockresponsewriter/mockresponsewriter.go diff --git a/internal/config/supervisor/types.go b/internal/config/supervisor/types.go index f1b2870ec..4f94b81d6 100644 --- a/internal/config/supervisor/types.go +++ b/internal/config/supervisor/types.go @@ -7,7 +7,7 @@ import ( "go.pinniped.dev/internal/plog" ) -// Config contains knobs to setup an instance of the Pinniped Supervisor. +// Config contains knobs to set up an instance of the Pinniped Supervisor. type Config struct { APIGroupSuffix *string `json:"apiGroupSuffix,omitempty"` Labels map[string]string `json:"labels"` @@ -16,6 +16,15 @@ type Config struct { Endpoints *Endpoints `json:"endpoints"` AggregatedAPIServerPort *int64 `json:"aggregatedAPIServerPort"` TLS TLSSpec `json:"tls"` + Audit AuditSpec `json:"audit"` +} + +type AuditInternalPaths string + +const AuditInternalPathsEnabled = "Enabled" + +type AuditSpec struct { + InternalPaths AuditInternalPaths `json:"internalPaths"` } type TLSSpec struct { diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 78cac0624..8eb81b7df 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -36,20 +36,21 @@ const ( promptParamNone = "none" ) -//nolint:gochecknoglobals // please treat this as a readonly const, do not mutate -var paramsSafeToLog = sets.New[string]( - // Standard params from https://openid.net/specs/openid-connect-core-1_0.html, some of which are ignored. - // Redacting state and nonce params, in case they contain any info that the client considers sensitive. - "scope", "response_type", "client_id", "redirect_uri", "response_mode", "display", "prompt", - "max_age", "ui_locales", "id_token_hint", "login_hint", "acr_values", "claims_locales", "claims", - "request", "request_uri", "registration", - // PKCE params from https://datatracker.ietf.org/doc/html/rfc7636. Let code_challenge be redacted. - "code_challenge_method", - // Custom Pinniped authorization params. - oidcapi.AuthorizeUpstreamIDPNameParamName, oidcapi.AuthorizeUpstreamIDPTypeParamName, - // Google-specific param that some client libraries will send anyway. Ignored by Pinniped but safe to log. - "access_type", -) +func paramsSafeToLog() sets.Set[string] { + return sets.New[string]( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html, some of which are ignored. + // Redacting state and nonce params, in case they contain any info that the client considers sensitive. + "scope", "response_type", "client_id", "redirect_uri", "response_mode", "display", "prompt", + "max_age", "ui_locales", "id_token_hint", "login_hint", "acr_values", "claims_locales", "claims", + "request", "request_uri", "registration", + // PKCE params from https://datatracker.ietf.org/doc/html/rfc7636. Let code_challenge be redacted. + "code_challenge_method", + // Custom Pinniped authorization params. + oidcapi.AuthorizeUpstreamIDPNameParamName, oidcapi.AuthorizeUpstreamIDPTypeParamName, + // Google-specific param that some client libraries will send anyway. Ignored by Pinniped but safe to log. + "access_type", + ) +} type authorizeHandler struct { downstreamIssuerURL string @@ -140,7 +141,7 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), - "params", plog.SanitizeParams(r.Form, paramsSafeToLog)) + "params", plog.SanitizeParams(r.Form, paramsSafeToLog())) // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and // oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP. diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index cce08e8d9..8f6dfd5ef 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -30,16 +30,17 @@ import ( "go.pinniped.dev/internal/psession" ) -//nolint:gochecknoglobals // please treat this as a readonly const, do not mutate -var paramsSafeToLog = sets.New[string]( - // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. - // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. - "grant_type", "client_id", "redirect_uri", "scope", - // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. - // Redact subject_token and actor_token. - // We don't allow all of these, but they should be safe to log. - "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", -) +func paramsSafeToLog() sets.Set[string] { + return sets.New( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. + // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. + "grant_type", "client_id", "redirect_uri", "scope", + // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. + // Redact subject_token and actor_token. + // We don't allow all of these, but they should be safe to log. + "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", + ) +} func NewHandler( idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI, @@ -59,7 +60,7 @@ func NewHandler( // Note that r.PostForm and accessRequest were populated by NewAccessRequest(). auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), accessRequest, - "params", plog.SanitizeParams(r.PostForm, paramsSafeToLog)) + "params", plog.SanitizeParams(r.PostForm, paramsSafeToLog())) // Check if we are performing a refresh grant. if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) { diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index aa5d2602d..566d5c9a7 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -12,6 +12,7 @@ import ( corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" + "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/dynamiccodec" "go.pinniped.dev/internal/federationdomain/endpoints/auth" @@ -63,6 +64,7 @@ func NewManager( secretsClient corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface, auditLogger plog.AuditLogger, + auditCfg supervisor.AuditSpec, ) *Manager { m := &Manager{ providerHandlers: make(map[string]http.Handler), @@ -74,7 +76,7 @@ func NewManager( auditLogger: auditLogger, } // nextHandler is the next handler in the chain, called when this manager didn't know how to handle a request - m.buildHandlerChain(nextHandler) + m.buildHandlerChain(nextHandler, auditCfg) return m } @@ -191,11 +193,11 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro } } -func (m *Manager) buildHandlerChain(nextHandler http.Handler) { +func (m *Manager) buildHandlerChain(nextHandler http.Handler, auditCfg supervisor.AuditSpec) { // build the basic handler for FederationDomain endpoints handler := m.buildManagerHandler(nextHandler) // log all requests, including audit ID - handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger) + handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger, auditCfg) // add random audit ID to request context and response headers handler = requestlogger.WithAuditID(handler, func() string { return uuid.New().String() diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index cc4609209..9b026c33a 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "slices" "time" "k8s.io/apimachinery/pkg/types" @@ -15,7 +16,9 @@ import ( apisaudit "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/endpoints/responsewriter" + "k8s.io/utils/clock" + "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/httputil/requestutil" "go.pinniped.dev/internal/plog" ) @@ -41,9 +44,9 @@ func WithAuditID(handler http.Handler, newAuditIDFunc func() string) http.Handle }) } -func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger) http.Handler { +func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger, auditCfg supervisor.AuditSpec) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - rl := newRequestLogger(req, w, auditLogger, time.Now()) + rl := newRequestLogger(req, w, auditLogger, time.Now(), auditCfg) rl.LogRequestReceived() defer rl.LogRequestComplete() @@ -55,6 +58,7 @@ func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLog type requestLogger struct { startTime time.Time + clock clock.Clock // clock is used to calculate the response latency, and useful for unit tests. hijacked bool statusRecorded bool @@ -65,23 +69,37 @@ type requestLogger struct { w http.ResponseWriter auditLogger plog.AuditLogger + auditCfg supervisor.AuditSpec } -func newRequestLogger(req *http.Request, w http.ResponseWriter, auditLogger plog.AuditLogger, startTime time.Time) *requestLogger { +func newRequestLogger(req *http.Request, w http.ResponseWriter, auditLogger plog.AuditLogger, startTime time.Time, auditCfg supervisor.AuditSpec) *requestLogger { return &requestLogger{ req: req, w: w, startTime: startTime, + clock: clock.RealClock{}, userAgent: req.UserAgent(), // cache this from the req to avoid any possibility of concurrent read/write problems with headers map auditLogger: auditLogger, + auditCfg: auditCfg, + } +} + +func internalPaths() []string { + return []string{ + "/healthz", } } func (rl *requestLogger) LogRequestReceived() { r := rl.req + + if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { + return + } + rl.auditLogger.Audit(plog.AuditEventHTTPRequestReceived, r.Context(), - nil, // no session available yet in this context + plog.NoSessionPersisted(), "proto", r.Proto, "method", r.Method, "host", r.Host, @@ -94,7 +112,12 @@ func (rl *requestLogger) LogRequestReceived() { func (rl *requestLogger) LogRequestComplete() { r := rl.req - location := rl.w.Header().Get("Location") + + if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { + return + } + + location := rl.Header().Get("Location") if location == "" { location = "no location header" } else { @@ -110,9 +133,9 @@ func (rl *requestLogger) LogRequestComplete() { rl.auditLogger.Audit(plog.AuditEventHTTPRequestCompleted, r.Context(), - nil, // no session available yet in this context + plog.NoSessionPersisted(), "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events - "latency", time.Since(rl.startTime), + "latency", rl.clock.Since(rl.startTime), "responseStatus", rl.status, "location", location, ) diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go new file mode 100644 index 000000000..2e2aae20d --- /dev/null +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -0,0 +1,225 @@ +package requestlogger + +import ( + "crypto/tls" + "net/http" + "net/url" + "testing" + "time" + + "go.uber.org/mock/gomock" + clocktesting "k8s.io/utils/clock/testing" + + "go.pinniped.dev/internal/config/supervisor" + "go.pinniped.dev/internal/mocks/mockresponsewriter" + "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/testutil" +) + +func TestLogRequestReceived(t *testing.T) { + var noAuditEventsWanted []testutil.WantedAuditLog + + happyAuditEventWanted := func(path string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Received", + map[string]any{ + "proto": "some-proto", + "method": "some-method", + "host": "some-host", + "serverName": "some-sni-server-name", + "path": path, + "userAgent": "some-user-agent", + "remoteAddr": "some-remote-addr", + }, + ), + } + } + + tests := []struct { + name string + path string + auditCfg supervisor.AuditSpec + wantAuditLogs []testutil.WantedAuditLog + }{ + { + name: "when internal paths are not Enabled, ignores internal paths", + path: "/healthz", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: noAuditEventsWanted, + }, + { + name: "when internal paths are not Enabled, audits external path", + path: "/pretend-to-login", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), + }, + { + name: "when internal paths are Enabled, audits internal paths", + path: "/healthz", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Enabled", + }, + wantAuditLogs: happyAuditEventWanted("/healthz"), + }, + { + name: "when internal paths are Enabled, audits external paths", + path: "/pretend-to-login", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Enabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + logger, log := plog.TestLogger(t) + + subject := requestLogger{ + auditLogger: logger, + req: &http.Request{ + Method: "some-method", + Proto: "some-proto", + Host: "some-host", + URL: &url.URL{ + Path: test.path, + }, + RemoteAddr: "some-remote-addr", + TLS: &tls.ConnectionState{ + ServerName: "some-sni-server-name", + }, + }, + userAgent: "some-user-agent", + auditCfg: test.auditCfg, + } + + subject.LogRequestReceived() + + testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) + }) + } +} + +func TestLogRequestComplete(t *testing.T) { + wantLatency := time.Minute + 2*time.Second + 345*time.Millisecond + + var noAuditEventsWanted []testutil.WantedAuditLog + + happyAuditEventWanted := func(path, location string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Completed", + map[string]any{ + "path": path, + "latency": "1m2.345s", + "responseStatus": 777.0, // JSON serializes this as a float + "location": location, + }, + ), + } + } + + tests := []struct { + name string + path string + location string + auditCfg supervisor.AuditSpec + wantAuditLogs []testutil.WantedAuditLog + }{ + { + name: "when internal paths are not Enabled, ignores internal paths", + path: "/healthz", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: noAuditEventsWanted, + }, + { + name: "when internal paths are not Enabled, audits external path with location", + path: "/pretend-to-login", + location: "some-location", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), + }, + { + name: "when internal paths are not Enabled, audits external path without location", + path: "/pretend-to-login", + location: "", // make it obvious + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "no location header"), + }, + { + name: "when internal paths are not Enabled, audits external path with invalid location", + path: "/pretend-to-login", + location: "http://e x a m p l e.com", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Disabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "unparsable location header"), + }, + { + name: "when internal paths are Enabled, audits internal paths", + path: "/healthz", + location: "some-location", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Enabled", + }, + wantAuditLogs: happyAuditEventWanted("/healthz", "some-location"), + }, + { + name: "when internal paths are Enabled, audits external paths", + path: "/pretend-to-login", + location: "some-location", + auditCfg: supervisor.AuditSpec{ + InternalPaths: "Enabled", + }, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), + }, + } + + nowDoesntMatter := time.Date(1122, time.September, 33, 4, 55, 56, 778899, time.Local) + startTime := nowDoesntMatter.Add(-wantLatency) + frozenClock := clocktesting.NewFakeClock(nowDoesntMatter) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockResponseWriter := mockresponsewriter.NewMockResponseWriter(ctrl) + if len(test.wantAuditLogs) > 0 { + mockResponseWriter.EXPECT().Header().Return(http.Header{ + "Location": []string{test.location}, + }) + } + + logger, log := plog.TestLogger(t) + + subject := requestLogger{ + auditLogger: logger, + startTime: startTime, + clock: frozenClock, + req: &http.Request{ + URL: &url.URL{ + Path: test.path, + }, + }, + status: 777, + w: mockResponseWriter, + auditCfg: test.auditCfg, + } + + subject.LogRequestComplete() + + testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) + }) + } +} diff --git a/internal/mocks/mockresponsewriter/generate.go b/internal/mocks/mockresponsewriter/generate.go new file mode 100644 index 000000000..a3a65baf1 --- /dev/null +++ b/internal/mocks/mockresponsewriter/generate.go @@ -0,0 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mockresponsewriter + +//go:generate go run -v go.uber.org/mock/mockgen -destination=mockresponsewriter.go -package=mockresponsewriter -copyright_file=../../../hack/header.txt net/http ResponseWriter diff --git a/internal/mocks/mockresponsewriter/mockresponsewriter.go b/internal/mocks/mockresponsewriter/mockresponsewriter.go new file mode 100644 index 000000000..85c890f1d --- /dev/null +++ b/internal/mocks/mockresponsewriter/mockresponsewriter.go @@ -0,0 +1,86 @@ +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: net/http (interfaces: ResponseWriter) +// +// Generated by this command: +// +// mockgen -destination=mockresponsewriter.go -package=mockresponsewriter -copyright_file=../../../hack/header.txt net/http ResponseWriter +// + +// Package mockresponsewriter is a generated GoMock package. +package mockresponsewriter + +import ( + http "net/http" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockResponseWriter is a mock of ResponseWriter interface. +type MockResponseWriter struct { + ctrl *gomock.Controller + recorder *MockResponseWriterMockRecorder + isgomock struct{} +} + +// MockResponseWriterMockRecorder is the mock recorder for MockResponseWriter. +type MockResponseWriterMockRecorder struct { + mock *MockResponseWriter +} + +// NewMockResponseWriter creates a new mock instance. +func NewMockResponseWriter(ctrl *gomock.Controller) *MockResponseWriter { + mock := &MockResponseWriter{ctrl: ctrl} + mock.recorder = &MockResponseWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResponseWriter) EXPECT() *MockResponseWriterMockRecorder { + return m.recorder +} + +// Header mocks base method. +func (m *MockResponseWriter) Header() http.Header { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Header") + ret0, _ := ret[0].(http.Header) + return ret0 +} + +// Header indicates an expected call of Header. +func (mr *MockResponseWriterMockRecorder) Header() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockResponseWriter)(nil).Header)) +} + +// Write mocks base method. +func (m *MockResponseWriter) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockResponseWriterMockRecorder) Write(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockResponseWriter)(nil).Write), arg0) +} + +// WriteHeader mocks base method. +func (m *MockResponseWriter) WriteHeader(statusCode int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "WriteHeader", statusCode) +} + +// WriteHeader indicates an expected call of WriteHeader. +func (mr *MockResponseWriterMockRecorder) WriteHeader(statusCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteHeader", reflect.TypeOf((*MockResponseWriter)(nil).WriteHeader), statusCode) +} diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index 8fc9b9d58..871b9abf3 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -485,6 +485,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders client.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), plog.New(), + cfg.Audit, ) // Get the "real" name of the client secret supervisor API group (i.e., the API group name with the diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index fb85f3a70..978fc28ab 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -116,7 +116,7 @@ TODO: Document this configuration, probably something like so: ```yaml audit: show_personally_identifiable_information: enabled - audit_internal_endpoints: enabled + internal_endpoints: enabled ``` # From 1006dd93796d60e5fdbb7104090cd0a152669779 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 4 Nov 2024 11:10:37 -0800 Subject: [PATCH 17/71] resolve some todos --- .../supervisorstorage/garbage_collector.go | 7 ++-- .../endpoints/auth/auth_handler_test.go | 3 ++ .../callback/callback_handler_test.go | 7 ++-- .../endpoints/login/login_handler_test.go | 3 ++ .../endpointsmanager/manager.go | 32 ++++++------------- .../requestlogger/request_logger.go | 18 +++++++---- .../requestlogger/request_logger_test.go | 4 +-- internal/plog/plog.go | 1 - internal/testutil/log_lines.go | 11 ++++--- site/content/docs/reference/audit-logging.md | 2 +- 10 files changed, 46 insertions(+), 42 deletions(-) diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 0ed723197..4e92f1da8 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -283,7 +283,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, nil, request, + c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, "type", upstreamprovider.RefreshTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC refresh token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -293,7 +293,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, nil, request, + c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, "type", upstreamprovider.AccessTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC access token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -304,7 +304,8 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( func (c *garbageCollectorController) maybeAuditLogGC(storageType string, secret *corev1.Secret) { r, err := c.requestFromSecret(storageType, secret) if err == nil && r != nil { - c.auditLogger.Audit(plog.AuditEventSessionGarbageCollected, nil, r, "storageType", storageType) + c.auditLogger.Audit(plog.AuditEventSessionGarbageCollected, plog.NoHTTPRequestAvailable(), r, + "storageType", storageType) } } diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 5a1dd4e35..c71cefb62 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -35,6 +35,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" @@ -3803,6 +3804,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if test.customPasswordHeader != nil { req.Header.Set("Pinniped-Password", *test.customPasswordHeader) } + req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() subject.ServeHTTP(rsp, req) @@ -3874,6 +3876,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(stateparam.Encoded(actualQueryStateParam), sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) } diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 19ae26a39..01bcb8fd3 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -25,6 +25,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/federationdomain/upstreamprovider" @@ -1868,6 +1869,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } + req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) @@ -1877,13 +1879,13 @@ func TestCallbackEndpoint(t *testing.T) { switch { case test.wantOIDCAuthcodeExchangeCall != nil: - test.wantOIDCAuthcodeExchangeCall.args.Ctx = reqContext + test.wantOIDCAuthcodeExchangeCall.args.Ctx = req.Context() test.idps.RequireExactlyOneOIDCAuthcodeExchange(t, test.wantOIDCAuthcodeExchangeCall.performedByUpstreamName, test.wantOIDCAuthcodeExchangeCall.args, ) case test.wantGitHubAuthcodeExchangeCall != nil: - test.wantGitHubAuthcodeExchangeCall.args.Ctx = reqContext + test.wantGitHubAuthcodeExchangeCall.args.Ctx = req.Context() test.idps.RequireExactlyOneGitHubAuthcodeExchange(t, test.wantGitHubAuthcodeExchangeCall.performedByUpstreamName, test.wantGitHubAuthcodeExchangeCall.args, @@ -1956,6 +1958,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path), sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) } }) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 776b93ca5..fa753b05f 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/plog" @@ -411,6 +412,7 @@ func TestLoginEndpoint(t *testing.T) { if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } + req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() testGetHandler := func( @@ -465,6 +467,7 @@ func TestLoginEndpoint(t *testing.T) { if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path)) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) } }) diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index 566d5c9a7..b3a0a60ee 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -8,7 +8,6 @@ import ( "strings" "sync" - "github.com/google/uuid" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" @@ -29,7 +28,6 @@ import ( "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/storage" - "go.pinniped.dev/internal/httputil/requestutil" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/secret" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -194,36 +192,24 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro } func (m *Manager) buildHandlerChain(nextHandler http.Handler, auditCfg supervisor.AuditSpec) { - // build the basic handler for FederationDomain endpoints + // Build the basic handler for FederationDomain endpoints. handler := m.buildManagerHandler(nextHandler) - // log all requests, including audit ID + // Log all requests, including audit ID. handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger, auditCfg) - // add random audit ID to request context and response headers - handler = requestlogger.WithAuditID(handler, func() string { - return uuid.New().String() - }) + // Add random audit ID to request context and response headers. + handler = requestlogger.WithAuditID(handler) m.handlerChain = handler } func (m *Manager) buildManagerHandler(nextHandler http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { requestHandler := m.findHandler(req) - - // TODO: Should this old log message change in light of the new audit logs? Or do we not want to force people to enable audit logs to debug this SNI stuff? - // Using Info level so the user can safely configure a production Supervisor to show this message if they choose. - plog.Info("received incoming request", - "proto", req.Proto, - "method", req.Method, - "host", req.Host, - "requestSNIServerName", requestutil.SNIServerName(req), - "path", req.URL.Path, - "remoteAddr", req.RemoteAddr, - "userAgent", req.UserAgent(), - "foundFederationDomainRequestHandler", requestHandler != nil, - ) - if requestHandler == nil { - requestHandler = nextHandler // couldn't find an issuer to handle the request + // Couldn't find any FederationDomain to handle this request based on the request's host and path. + // It could be a bad request to a path that does not exist. Or it could be because something in + // front of the Supervisor is not passing the SNI of the original request through to the Supervisor. + // All 404's will be logged by request_logger.go, so we don't need to log it here. + requestHandler = nextHandler } requestHandler.ServeHTTP(resp, req) }) diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 9b026c33a..d82a78494 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -11,6 +11,7 @@ import ( "slices" "time" + "github.com/google/uuid" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" apisaudit "k8s.io/apiserver/pkg/apis/audit" @@ -23,6 +24,7 @@ import ( "go.pinniped.dev/internal/plog" ) +// NewRequestWithAuditID is public for use in unit tests. Production code should use WithAuditID(). func NewRequestWithAuditID(r *http.Request, newAuditIDFunc func() string) (*http.Request, string) { ctx := audit.WithAuditContext(r.Context()) r = r.WithContext(ctx) @@ -33,9 +35,12 @@ func NewRequestWithAuditID(r *http.Request, newAuditIDFunc func() string) (*http return r, auditID } -func WithAuditID(handler http.Handler, newAuditIDFunc func() string) http.Handler { +func WithAuditID(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r, auditID := NewRequestWithAuditID(r, newAuditIDFunc) + // Add a randomly generated request ID to the context for this request. + r, auditID := NewRequestWithAuditID(r, func() string { + return uuid.New().String() + }) // Send the Audit-ID response header. w.Header().Set(apisaudit.HeaderAuditID, auditID) @@ -48,8 +53,8 @@ func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLog return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { rl := newRequestLogger(req, w, auditLogger, time.Now(), auditCfg) - rl.LogRequestReceived() - defer rl.LogRequestComplete() + rl.logRequestReceived() + defer rl.logRequestComplete() statusCodeCapturingResponseWriter := responsewriter.WrapForHTTP1Or2(rl) handler.ServeHTTP(statusCodeCapturingResponseWriter, req) @@ -90,13 +95,14 @@ func internalPaths() []string { } } -func (rl *requestLogger) LogRequestReceived() { +func (rl *requestLogger) logRequestReceived() { r := rl.req if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { return } + // Always log all other requests, including 404's caused by bad paths, for debugging purposes. rl.auditLogger.Audit(plog.AuditEventHTTPRequestReceived, r.Context(), plog.NoSessionPersisted(), @@ -110,7 +116,7 @@ func (rl *requestLogger) LogRequestReceived() { ) } -func (rl *requestLogger) LogRequestComplete() { +func (rl *requestLogger) logRequestComplete() { r := rl.req if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index 2e2aae20d..82a12c3c5 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -98,7 +98,7 @@ func TestLogRequestReceived(t *testing.T) { auditCfg: test.auditCfg, } - subject.LogRequestReceived() + subject.logRequestReceived() testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) }) @@ -217,7 +217,7 @@ func TestLogRequestComplete(t *testing.T) { auditCfg: test.auditCfg, } - subject.LogRequestComplete() + subject.logRequestComplete() testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) }) diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 6253e8027..81e9893da 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -126,7 +126,6 @@ func (p pLogger) Error(msg string, err error, keysAndValues ...any) { // by their own separate configuration. This is because Audit logs should always be printed when they are desired // by the admin, regardless of global log level, yet the admin should also have a way to entirely disable them // when they want to avoid potential PII (e.g. usernames) in their pod logs. -// TODO: Add a way to disable output of audit logs, separate from the log level config. func (p pLogger) Audit(msg AuditEventMessage, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { // Always add a key/value auditEvent=true. keysAndValues = slices.Concat([]any{"auditEvent", true}, keysAndValues) diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index d60ae0bbf..048e7c916 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -30,17 +30,20 @@ type WantedAuditLog struct { Params map[string]any } -func WantAuditLog(message string, params map[string]any, auditID ...string) WantedAuditLog { +func WantAuditLog(message string, params map[string]any) WantedAuditLog { result := WantedAuditLog{ Message: message, Params: params, } - if len(auditID) > 0 { - result.Params["auditID"] = auditID[0] - } return result } +func WantAuditIDOnEveryAuditLog(wantedAuditLogs []WantedAuditLog, wantAuditID string) { + for _, wantedAuditLog := range wantedAuditLogs { + wantedAuditLog.Params["auditID"] = wantAuditID + } +} + func GetStateParam(t *testing.T, fullURL string) stateparam.Encoded { var encodedStateParam stateparam.Encoded if fullURL != "" { diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index 978fc28ab..23f43cc34 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -64,7 +64,7 @@ Every log line in a Supervisor or Concierge pod log is a JSON object. Only those key/value pair `"auditEvent": true` are audit events. Other lines are for errors, warnings, and debugging information. -Every audit log contains the following keys, and audit event lines also contain these common keys/values: +Every line in the pod logs contains the following common keys/values, including audit event log lines: - `timestamp`, whose value is in UTC time, e.g. `2024-07-10T20:03:26.164470Z` - `level`, which for an audit event will always have the value `info` From 362d982906b60f4d55548eaf6438361544cc4c3c Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 4 Nov 2024 14:24:19 -0600 Subject: [PATCH 18/71] Start to backfill some audit unit tests for the token_handler --- .../endpoints/token/token_handler_test.go | 112 ++++++++++++++++-- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 3317343e7..0d32b92d6 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -4,6 +4,7 @@ package token import ( + "bytes" "context" "crypto/ecdsa" "crypto/elliptic" @@ -310,6 +311,7 @@ type tokenEndpointResponseExpectedValues struct { // The expected lifetime of the ID tokens issued by authcode exchange and refresh, but not token exchange. // When zero, will assume that the test wants the default value for ID token lifetime. wantIDTokenLifetimeSeconds int + wantAuditLogs func(sessionID string) []testutil.WantedAuditLog } func withWantCustomIDTokenLifetime(wantIDTokenLifetimeSeconds int, w tokenEndpointResponseExpectedValues) tokenEndpointResponseExpectedValues { @@ -384,6 +386,14 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -441,6 +451,14 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -519,6 +537,14 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantUsername: goodUsername, wantGroups: goodGroups, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -980,7 +1006,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // First call - should be successful. // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - subject, rsp, authCode, _, secrets, oauthStore := exchangeAuthcodeForTokens(t, + subject, rsp, authCode, _, secrets, oauthStore, _, _ := exchangeAuthcodeForTokens(t, test.authcodeExchange, testidplister.NewUpstreamIDPListerBuilder().BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -1075,6 +1101,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus int wantErrorType string wantErrorDescContains string + wantAuditLogs func(sessionID string) []testutil.WantedAuditLog }{ { name: "happy path", @@ -1118,10 +1145,35 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "name": "value", }, }, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": func() string { + params := url.Values{} + params.Set("audience", "some-workload-cluster") + params.Set("client_id", "pinniped-cli") + params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") + params.Set("subject_token", "redacted") + params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + return params.Encode() + }(), + }), + } + }, }, { name: "happy path without requesting username and groups scopes", @@ -1658,14 +1710,14 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn t.Parallel() // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, + subject, rsp, _, _, secrets, oauthStore, log, sessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, testidplister.NewUpstreamIDPListerBuilder().BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) request := happyTokenExchangeRequest(test.requestedAudience, parsedAuthcodeExchangeResponseBody["access_token"].(string)) if test.modifyStorage != nil { - test.modifyStorage(t, storage, secrets, request) + test.modifyStorage(t, oauthStore, secrets, request) } if test.modifyRequestParams != nil { test.modifyRequestParams(t, request.Form) @@ -1689,6 +1741,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn // Perform the token exchange. approxRequestTime := time.Now() + log.Reset() subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) @@ -1696,6 +1749,10 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") + if test.wantAuditLogs != nil { + testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), log.String()) + } + var parsedResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -2047,6 +2104,14 @@ func TestRefreshGrant(t *testing.T) { if expectToValidateToken != nil { want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true) } + want.wantAuditLogs = func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + } return want } @@ -2364,6 +2429,14 @@ func TestRefreshGrant(t *testing.T) { "error_description": "Error during upstream refresh. Upstream refresh rejected by configured identity policy: authentication was rejected by a configured policy." } `), + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -2411,6 +2484,14 @@ func TestRefreshGrant(t *testing.T) { "name": "value", }, }, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "sessionID": sessionID, + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, refreshRequest: refreshRequestInputs{ @@ -4707,9 +4788,9 @@ func TestRefreshGrant(t *testing.T) { t.Parallel() // First exchange the authcode for tokens, including a refresh token. - // its actually fine to use this function even when simulating ldap (which uses a different flow) because it's + // It's actually fine to use this function even when simulating LDAP (which uses a different flow) because it's // just populating a secret in storage. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, _, _ := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -4883,6 +4964,8 @@ func exchangeAuthcodeForTokens( jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, oauthStore *storage.KubeStorage, + log *bytes.Buffer, + sessionID string, ) { authRequest := deepCopyRequestForm(happyAuthRequest) if test.modifyAuthRequest != nil { @@ -4912,12 +4995,14 @@ func exchangeAuthcodeForTokens( // Note that makeHappyOauthHelper() calls simulateAuthEndpointHavingAlreadyRun() to preload the session storage. oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession) + logger, log := plog.TestLogger(t) + subject = NewHandler( idps, oauthHelper, timeoutsConfiguration.OverrideDefaultAccessTokenLifespan, timeoutsConfiguration.OverrideDefaultIDTokenLifespan, - plog.New(), + logger, ) authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid") @@ -4944,6 +5029,19 @@ func exchangeAuthcodeForTokens( t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) + if test.want.wantAuditLogs != nil { + authCodeLabelSelector := fmt.Sprintf("%s=%s", crud.SecretLabelKey, authorizationcode.TypeLabelValue) + allAuthCodeSecrets, _ := secrets.List(context.Background(), metav1.ListOptions{ + LabelSelector: authCodeLabelSelector, + }) + require.NotNil(t, allAuthCodeSecrets) + require.Len(t, allAuthCodeSecrets.Items, 1, "expected exactly one secret with label %s", authCodeLabelSelector) + session, err := authorizationcode.ReadFromSecret(&allAuthCodeSecrets.Items[0]) + require.NoError(t, err) + sessionID = session.Request.GetID() + testutil.CompareAuditLogs(t, test.want.wantAuditLogs(sessionID), log.String()) + } + wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) requireTokenEndpointBehavior( @@ -4958,7 +5056,7 @@ func exchangeAuthcodeForTokens( approxRequestTime, ) - return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore + return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, log, sessionID } func requireTokenEndpointBehavior( From 0d22ae2c1a90621255faf371a184818b9cc489d3 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 4 Nov 2024 14:41:13 -0600 Subject: [PATCH 19/71] Fix lint and unit test compilation --- .../endpointsmanager/manager_test.go | 12 +++++++++++- .../requestlogger/request_logger_test.go | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/federationdomain/endpointsmanager/manager_test.go b/internal/federationdomain/endpointsmanager/manager_test.go index d951b9767..b92af11da 100644 --- a/internal/federationdomain/endpointsmanager/manager_test.go +++ b/internal/federationdomain/endpointsmanager/manager_test.go @@ -20,6 +20,7 @@ import ( "k8s.io/client-go/kubernetes/fake" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/federationdomain/endpoints/discovery" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/federationdomainproviders" @@ -359,7 +360,16 @@ func TestManager(t *testing.T) { cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2")) cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02")) - subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient, plog.New()) + subject = NewManager( + nextHandler, + dynamicJWKSProvider, + idpLister, + &cache, + secretsClient, + oidcClientsClient, + plog.New(), + supervisor.AuditSpec{}, + ) }) when("given no providers via SetFederationDomains()", func() { diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index 82a12c3c5..d9345d14b 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -1,3 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + package requestlogger import ( From dc6faa33bb4198f8cc76e0b9add8df1d67569cf7 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 6 Nov 2024 10:26:28 -0600 Subject: [PATCH 20/71] Log params to token_handler endpoint even during error cases --- .../endpoints/auth/auth_handler_test.go | 4 +- .../callback/callback_handler_test.go | 2 +- .../login/post_login_handler_test.go | 2 +- .../endpoints/token/token_handler.go | 28 +-- .../endpoints/token/token_handler_test.go | 180 +++++++++++++----- .../tokenendpointauditor/parameter_auditor.go | 59 ++++++ .../endpointsmanager/manager.go | 2 + internal/federationdomain/oidc/oidc.go | 4 + internal/testutil/log_lines.go | 6 +- 9 files changed, 218 insertions(+), 69 deletions(-) create mode 100644 internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index c71cefb62..bcf2db9ba 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -272,14 +272,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo // Inject this into our test subject at the last second so we get a fresh storage for every test. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. kubeOauthStore := storage.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) - return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore + return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil), kubeOauthStore } createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *storage.NullStorage) { // Configure fosite the same way that the production code would, using NullStorage to turn off storage. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. nullOauthStore := storage.NewNullStorage(secretsClient, oidcClientsClient, bcrypt.MinCost) - return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore + return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil), nullOauthStore } upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 01bcb8fd3..583fdc02f 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -1851,7 +1851,7 @@ func TestCallbackEndpoint(t *testing.T) { hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil) logger, log := plog.TestLogger(t) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 24591b9a5..3a0545533 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -1137,7 +1137,7 @@ func TestPostLoginEndpoint(t *testing.T) { hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil) req := httptest.NewRequest(http.MethodPost, "/ignored", strings.NewReader(tt.formParams.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 8f6dfd5ef..e50811748 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -30,18 +30,6 @@ import ( "go.pinniped.dev/internal/psession" ) -func paramsSafeToLog() sets.Set[string] { - return sets.New( - // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. - // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. - "grant_type", "client_id", "redirect_uri", "scope", - // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. - // Redact subject_token and actor_token. - // We don't allow all of these, but they should be safe to log. - "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", - ) -} - func NewHandler( idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI, oauthHelper fosite.OAuth2Provider, @@ -58,10 +46,6 @@ func NewHandler( return nil } - // Note that r.PostForm and accessRequest were populated by NewAccessRequest(). - auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), accessRequest, - "params", plog.SanitizeParams(r.PostForm, paramsSafeToLog())) - // Check if we are performing a refresh grant. if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) { // The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable. @@ -226,17 +210,19 @@ func upstreamRefresh( refreshedIdentity.UpstreamGroups = oldUntransformedGroups } - refreshedTransformedUsername, refreshedTransformedGroups, err := applyIdentityTransformationsDuringRefresh(ctx, + refreshedTransformedUsername, refreshedTransformedGroups, fositeErr := applyIdentityTransformationsDuringRefresh(ctx, idp.GetTransforms(), refreshedIdentity.UpstreamUsername, refreshedIdentity.UpstreamGroups, providerName, providerType, ) - if err != nil { + if fositeErr != nil { + // The HintField is always populated by applyIdentityTransformationsDuringRefresh, + // and more descriptive than fositeErr.Error() which is just "error". auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, accessRequest, - "reason", err) - return err + "reason", fositeErr.HintField) + return fositeErr } if oldTransformedUsername != refreshedTransformedUsername { @@ -299,7 +285,7 @@ func applyIdentityTransformationsDuringRefresh( upstreamGroups []string, providerName string, providerType psession.ProviderType, -) (string, []string, error) { +) (string, []string, *fosite.RFC6749Error) { transformationResult, err := transforms.Evaluate(ctx, upstreamUsername, upstreamGroups) if err != nil { return "", nil, errUpstreamRefreshError().WithHintf( diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 0d32b92d6..40ae7c6e0 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -389,8 +389,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", }), } }, @@ -454,8 +453,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": "code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", }), } }, @@ -540,8 +538,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", }), } }, @@ -937,6 +934,13 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": "client_id=pinniped-cli&code=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -951,6 +955,13 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + }), + } + }, }, }, }, @@ -1148,8 +1159,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", }), } }, @@ -1160,7 +1170,6 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, "params": func() string { params := url.Values{} params.Set("audience", "some-workload-cluster") @@ -1347,6 +1356,21 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "unauthorized_client", wantErrorDescContains: `The client is not authorized to request a token using this method. The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'.`, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": func() string { + params := url.Values{} + params.Set("audience", "some-workload-cluster") + params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") + params.Set("subject_token", "redacted") + params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + return params.Encode() + }(), + }), + } + }, }, { name: "dynamic client did not ask for the pinniped:request-audience scope in the original authorization request, so the access token submitted during token exchange lacks the scope", @@ -1445,6 +1469,22 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "invalid_request", wantErrorDescContains: "Missing 'audience' parameter.", + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": func() string { + params := url.Values{} + params.Set("audience", "") // make it obvious + params.Set("client_id", "pinniped-cli") + params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") + params.Set("subject_token", "redacted") + params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + return params.Encode() + }(), + }), + } + }, }, { name: "bad requested audience when it looks like the name of an OIDCClient CR", @@ -1710,7 +1750,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn t.Parallel() // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - subject, rsp, _, _, secrets, oauthStore, log, sessionID := exchangeAuthcodeForTokens(t, + subject, rsp, _, _, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, testidplister.NewUpstreamIDPListerBuilder().BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -1741,7 +1781,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn // Perform the token exchange. approxRequestTime := time.Now() - log.Reset() + actualAuditLog.Reset() // Clear audit logs from the authcode exchange subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) @@ -1750,7 +1790,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") if test.wantAuditLogs != nil { - testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), log.String()) + testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), actualAuditLog.String()) } var parsedResponseBody map[string]any @@ -2104,17 +2144,14 @@ func TestRefreshGrant(t *testing.T) { if expectToValidateToken != nil { want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken, true) } - want.wantAuditLogs = func(sessionID string) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", - }), - } - } return want } + refreshResponseWithAuditLogs := func(expectedValues tokenEndpointResponseExpectedValues, wantAuditLogs func(sessionID string) []testutil.WantedAuditLog) tokenEndpointResponseExpectedValues { + expectedValues.wantAuditLogs = wantAuditLogs + return expectedValues + } + happyRefreshTokenResponseForOpenIDAndOfflineAccessWithUsernameAndGroups := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token, wantDownstreamUsername string, wantDownstreamGroups []string) tokenEndpointResponseExpectedValues { // Should always have some custom session data stored. The other expectations happens to be the // same as the same values as the authcode exchange case. @@ -2259,9 +2296,39 @@ func TestRefreshGrant(t *testing.T) { }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: happyAuthcodeExchangeInputsForOIDCUpstream, refreshRequest: refreshRequestInputs{ - want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( - upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), - refreshedUpstreamTokensWithIDAndRefreshTokens(), + want: refreshResponseWithAuditLogs( + happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + refreshedUpstreamTokensWithIDAndRefreshTokens(), + ), + func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": func() string { + params := url.Values{} + params.Set("client_id", "pinniped-cli") + params.Set("grant_type", "refresh_token") + params.Set("refresh_token", "redacted") + params.Set("scope", "openid") + return params.Encode() + }(), + }), + testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ + "sessionID": sessionID, + "upstreamGroups": []any{}, + "upstreamUsername": "some-username", + }), + testutil.WantAuditLog("Session Refreshed", map[string]any{ + "sessionID": sessionID, + "username": "some-username", + "groups": []any{ + "group1", + "groups2", + }, + "subject": "https://issuer?sub=some-subject", + }), + } + }, ), }, }, @@ -2432,8 +2499,23 @@ func TestRefreshGrant(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": func() string { + params := url.Values{} + params.Set("client_id", "pinniped-cli") + params.Set("grant_type", "refresh_token") + params.Set("refresh_token", "redacted") + params.Set("scope", "openid") + return params.Encode() + }(), + }), + testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ + "sessionID": sessionID, + "upstreamGroups": []any{}, + "upstreamUsername": "some-username", + }), + testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "reason": "Upstream refresh rejected by configured identity policy: authentication was rejected by a configured policy.", }), } }, @@ -2487,8 +2569,7 @@ func TestRefreshGrant(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "sessionID": sessionID, - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", }), } }, @@ -4790,7 +4871,7 @@ func TestRefreshGrant(t *testing.T) { // First exchange the authcode for tokens, including a refresh token. // It's actually fine to use this function even when simulating LDAP (which uses a different flow) because it's // just populating a secret in storage. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, _, _ := exchangeAuthcodeForTokens(t, + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -4823,12 +4904,17 @@ func TestRefreshGrant(t *testing.T) { test.refreshRequest.modifyTokenRequest(req, firstRefreshToken, parsedAuthcodeExchangeResponseBody["access_token"].(string)) } + actualAuditLog.Reset() // Clear audit logs from the authcode exchange refreshResponse := httptest.NewRecorder() approxRequestTime := time.Now() subject.ServeHTTP(refreshResponse, req) t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) + if test.refreshRequest.want.wantAuditLogs != nil { + testutil.CompareAuditLogs(t, test.refreshRequest.want.wantAuditLogs(sessionID), actualAuditLog.String()) + } + // Test that we did or did not make a call to the upstream provider's interface to perform refresh. switch { case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil: @@ -4964,7 +5050,7 @@ func exchangeAuthcodeForTokens( jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, oauthStore *storage.KubeStorage, - log *bytes.Buffer, + actualAuditLog *bytes.Buffer, sessionID string, ) { authRequest := deepCopyRequestForm(happyAuthRequest) @@ -4991,11 +5077,11 @@ func exchangeAuthcodeForTokens( test.makeJwksSigningKeyAndProvider = generateJWTSigningKeyAndJWKSProvider } + logger, actualAuditLog := plog.TestLogger(t) + var oauthHelper fosite.OAuth2Provider // Note that makeHappyOauthHelper() calls simulateAuthEndpointHavingAlreadyRun() to preload the session storage. - oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession) - - logger, log := plog.TestLogger(t) + oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession, logger) subject = NewHandler( idps, @@ -5029,17 +5115,10 @@ func exchangeAuthcodeForTokens( t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) + sessionID = getSessionID(t, secrets) + if test.want.wantAuditLogs != nil { - authCodeLabelSelector := fmt.Sprintf("%s=%s", crud.SecretLabelKey, authorizationcode.TypeLabelValue) - allAuthCodeSecrets, _ := secrets.List(context.Background(), metav1.ListOptions{ - LabelSelector: authCodeLabelSelector, - }) - require.NotNil(t, allAuthCodeSecrets) - require.Len(t, allAuthCodeSecrets.Items, 1, "expected exactly one secret with label %s", authCodeLabelSelector) - session, err := authorizationcode.ReadFromSecret(&allAuthCodeSecrets.Items[0]) - require.NoError(t, err) - sessionID = session.Request.GetID() - testutil.CompareAuditLogs(t, test.want.wantAuditLogs(sessionID), log.String()) + testutil.CompareAuditLogs(t, test.want.wantAuditLogs(sessionID), actualAuditLog.String()) } wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) @@ -5056,7 +5135,21 @@ func exchangeAuthcodeForTokens( approxRequestTime, ) - return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, log, sessionID + return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID +} + +func getSessionID(t *testing.T, secrets v1.SecretInterface) string { + t.Helper() + + authCodeLabelSelector := fmt.Sprintf("%s=%s", crud.SecretLabelKey, authorizationcode.TypeLabelValue) + allAuthCodeSecrets, _ := secrets.List(context.Background(), metav1.ListOptions{ + LabelSelector: authCodeLabelSelector, + }) + require.NotNil(t, allAuthCodeSecrets) + require.Len(t, allAuthCodeSecrets.Items, 1, "expected exactly one secret with label %s", authCodeLabelSelector) + session, err := authorizationcode.ReadFromSecret(&allAuthCodeSecrets.Items[0]) + require.NoError(t, err) + return session.Request.GetID() } func requireTokenEndpointBehavior( @@ -5207,11 +5300,12 @@ func makeHappyOauthHelper( makeJwksSigningKeyAndProvider MakeJwksSigningKeyAndProviderFunc, initialCustomSessionData *psession.CustomSessionData, modifySession func(session *psession.PinnipedSession), + auditLogger plog.AuditLogger, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() jwtSigningKey, jwkProvider := makeJwksSigningKeyAndProvider(t, goodIssuer) - oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration(), auditLogger) authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper, initialCustomSessionData, modifySession) return oauthHelper, authResponder.GetCode(), jwtSigningKey } diff --git a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go new file mode 100644 index 000000000..d55e2b35e --- /dev/null +++ b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go @@ -0,0 +1,59 @@ +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tokenendpointauditor + +import ( + "context" + + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + "k8s.io/apimachinery/pkg/util/sets" + + "go.pinniped.dev/internal/plog" +) + +type parameterAuditorHandler struct { + auditLogger plog.AuditLogger +} + +func AuditorHandlerFactory(auditLogger plog.AuditLogger) compose.Factory { + return func(_ fosite.Configurator, _ any, _ any) any { + return ¶meterAuditorHandler{ + auditLogger: auditLogger, + } + } +} + +var _ fosite.TokenEndpointHandler = (*parameterAuditorHandler)(nil) + +func (p parameterAuditorHandler) PopulateTokenEndpointResponse(_ context.Context, _ fosite.AccessRequester, _ fosite.AccessResponder) error { + return nil +} + +func (p parameterAuditorHandler) HandleTokenEndpointRequest(_ context.Context, _ fosite.AccessRequester) error { + return nil +} + +func (p parameterAuditorHandler) CanSkipClientAuth(_ context.Context, _ fosite.AccessRequester) bool { + return false +} + +func paramsSafeToLogTokenEndpoint() sets.Set[string] { + return sets.New( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. + // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. + "grant_type", "client_id", "redirect_uri", "scope", + // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. + // Redact subject_token and actor_token. + // We don't allow all of these, but they should be safe to log. + "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", + ) +} + +func (p parameterAuditorHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { + p.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, ctx, plog.NoSessionPersisted(), + "params", plog.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())) + + return false +} diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index b3a0a60ee..5d0d943cd 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -119,6 +119,7 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro tokenHMACKeyGetter, nil, timeoutsConfiguration, + m.auditLogger, ) // For all the other endpoints, make another oauth helper with exactly the same settings except use real storage. @@ -128,6 +129,7 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro tokenHMACKeyGetter, m.dynamicJWKSProvider, timeoutsConfiguration, + m.auditLogger, ) upstreamStateEncoder := dynamiccodec.New( diff --git a/internal/federationdomain/oidc/oidc.go b/internal/federationdomain/oidc/oidc.go index db187acfc..77a9b3a28 100644 --- a/internal/federationdomain/oidc/oidc.go +++ b/internal/federationdomain/oidc/oidc.go @@ -22,6 +22,7 @@ import ( "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/endpoints/tokenendpointauditor" "go.pinniped.dev/internal/federationdomain/endpoints/tokenexchange" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/idtokenlifespan" @@ -231,6 +232,7 @@ func FositeOauth2Helper( hmacSecretOfLengthAtLeast32Func func() []byte, jwksProvider jwks.DynamicJWKSProvider, timeoutsConfiguration timeouts.Configuration, + auditLogger plog.AuditLogger, ) fosite.OAuth2Provider { oauthConfig := &fosite.Config{ IDTokenIssuer: issuer, @@ -271,6 +273,8 @@ func FositeOauth2Helper( CoreStrategy: strategy.NewDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func), OpenIDConnectTokenStrategy: strategy.NewDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), }, + // Put this before others to make sure it logs params! + tokenendpointauditor.AuditorHandlerFactory(auditLogger), compose.OAuth2AuthorizeExplicitFactory, compose.OAuth2RefreshTokenGrantFactory, // Use a custom factory to allow selective overrides of the ID token lifespan during authcode exchange. diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index 048e7c916..da776f201 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -68,10 +68,13 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL wantMessages := make([]string, 0) for _, wantAuditLog := range wantAuditLogs { wantJsonAuditLog := make(map[string]any) + require.Empty(t, wantAuditLog.Params["level"], "do not specify level in audit log expectations") wantJsonAuditLog["level"] = "info" + require.Empty(t, wantAuditLog.Params["message"], "do not specify message in audit log expectations") wantJsonAuditLog["message"] = wantAuditLog.Message wantMessages = append(wantMessages, wantAuditLog.Message) wantJsonAuditLog["auditEvent"] = true + require.Empty(t, wantAuditLog.Params["timestamp"], "do not specify timestamp in audit log expectations") wantJsonAuditLog["timestamp"] = "2099-08-08T13:57:36.123456Z" for k, v := range wantAuditLog.Params { wantJsonAuditLog[k] = v @@ -82,7 +85,8 @@ func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditL actualJsonAuditLogs := make([]map[string]any, 0) actualMessages := make([]string, 0) actualAuditLogs := strings.Split(actualAuditLogsOneLiner, "\n") - require.GreaterOrEqual(t, len(actualAuditLogs), 2) + require.GreaterOrEqual(t, len(actualAuditLogs), 2, + "expected %d log lines, found %d", len(wantAuditLogs), len(actualAuditLogs)-1) actualAuditLogs = actualAuditLogs[:len(actualAuditLogs)-1] // trim off the last "" for _, actualAuditLog := range actualAuditLogs { actualJsonAuditLog := make(map[string]any) From 18d3ab3d152b306ce0eaaa1b3cbc22f18fa289fd Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 7 Nov 2024 09:43:33 -0600 Subject: [PATCH 21/71] The 'HTTP Request Parameters' audit event now logs params as a JSON object --- .../endpoints/auth/auth_handler.go | 2 +- .../endpoints/auth/auth_handler_test.go | 155 ++++++++++++++++-- .../endpoints/token/token_handler_test.go | 134 +++++++++------ .../tokenendpointauditor/parameter_auditor.go | 2 +- .../requestlogger/request_logger.go | 11 +- .../requestlogger/request_logger_test.go | 6 +- internal/plog/audit_event.go | 42 +++-- internal/plog/audit_event_test.go | 120 ++++++++++++-- 8 files changed, 376 insertions(+), 96 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 8eb81b7df..f68ea7316 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -141,7 +141,7 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), - "params", plog.SanitizeParams(r.Form, paramsSafeToLog())) + plog.SanitizeParams(r.Form, paramsSafeToLog())...) // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and // oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP. diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index bcf2db9ba..510b0372a 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -731,7 +731,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", @@ -769,7 +779,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": dynamicClientID, + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid offline_access pinniped:request-audience username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", @@ -806,7 +826,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-github-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", @@ -844,7 +874,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=` + dynamicClientID + `&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-github-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": dynamicClientID, + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-github-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid offline_access pinniped:request-audience username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", @@ -881,7 +921,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-ldap-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", @@ -919,7 +969,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", @@ -958,7 +1017,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), } }, @@ -988,7 +1056,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", @@ -1102,7 +1180,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": true, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-password-granting-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", @@ -1177,7 +1265,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": true, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-password-granting-oidc-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-password-granting-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", @@ -1288,7 +1386,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": true, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-ldap-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", @@ -1348,7 +1456,17 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": true, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-ldap-idp&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-ldap-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", @@ -1720,7 +1838,18 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "Pinniped-Password": false, }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": `client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=some-oidc-idp&prompt=none&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&response_type=code&scope=openid+profile+email+username+groups&state=redacted`, + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-oidc-idp", + "prompt": "none", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 40ae7c6e0..4059b68bf 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -389,7 +389,13 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -453,7 +459,12 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -538,7 +549,13 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -937,7 +954,12 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -958,7 +980,13 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -1159,7 +1187,13 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, @@ -1170,16 +1204,14 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": func() string { - params := url.Values{} - params.Set("audience", "some-workload-cluster") - params.Set("client_id", "pinniped-cli") - params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") - params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") - params.Set("subject_token", "redacted") - params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") - return params.Encode() - }(), + "params": map[string]any{ + "audience": "some-workload-cluster", + "client_id": "pinniped-cli", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:jwt", + "subject_token": "redacted", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + }, }), } }, @@ -1359,15 +1391,13 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": func() string { - params := url.Values{} - params.Set("audience", "some-workload-cluster") - params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") - params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") - params.Set("subject_token", "redacted") - params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") - return params.Encode() - }(), + "params": map[string]any{ + "audience": "some-workload-cluster", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:jwt", + "subject_token": "redacted", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + }, }), } }, @@ -1472,16 +1502,14 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": func() string { - params := url.Values{} - params.Set("audience", "") // make it obvious - params.Set("client_id", "pinniped-cli") - params.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") - params.Set("requested_token_type", "urn:ietf:params:oauth:token-type:jwt") - params.Set("subject_token", "redacted") - params.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") - return params.Encode() - }(), + "params": map[string]any{ + "audience": "", // make it obvious + "client_id": "pinniped-cli", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:jwt", + "subject_token": "redacted", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + }, }), } }, @@ -2304,14 +2332,12 @@ func TestRefreshGrant(t *testing.T) { func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": func() string { - params := url.Values{} - params.Set("client_id", "pinniped-cli") - params.Set("grant_type", "refresh_token") - params.Set("refresh_token", "redacted") - params.Set("scope", "openid") - return params.Encode() - }(), + "params": map[string]any{ + "client_id": "pinniped-cli", + "grant_type": "refresh_token", + "refresh_token": "redacted", + "scope": "openid", + }, }), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ "sessionID": sessionID, @@ -2499,14 +2525,12 @@ func TestRefreshGrant(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": func() string { - params := url.Values{} - params.Set("client_id", "pinniped-cli") - params.Set("grant_type", "refresh_token") - params.Set("refresh_token", "redacted") - params.Set("scope", "openid") - return params.Encode() - }(), + "params": map[string]any{ + "client_id": "pinniped-cli", + "grant_type": "refresh_token", + "refresh_token": "redacted", + "scope": "openid", + }, }), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ "sessionID": sessionID, @@ -2569,7 +2593,13 @@ func TestRefreshGrant(t *testing.T) { wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": "client_id=pinniped-cli&code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback", + "params": map[string]any{ + "client_id": "pinniped-cli", + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, }), } }, diff --git a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go index d55e2b35e..37f5413c3 100644 --- a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go +++ b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go @@ -53,7 +53,7 @@ func paramsSafeToLogTokenEndpoint() sets.Set[string] { func (p parameterAuditorHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { p.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, ctx, plog.NoSessionPersisted(), - "params", plog.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())) + plog.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())...) return false } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index d82a78494..11e2400eb 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" apisaudit "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/endpoints/responsewriter" @@ -131,8 +130,14 @@ func (rl *requestLogger) logRequestComplete() { if err != nil { location = "unparsable location header" } else { - redactAllParams := sets.New[string]() - parsedLocation.RawQuery = plog.SanitizeParams(parsedLocation.Query(), redactAllParams) + // We don't know what this `Location` header is used for, so redact all query params + redactedParams := parsedLocation.Query() + for k, v := range redactedParams { + for i := range v { + redactedParams[k][i] = "redacted" + } + } + parsedLocation.RawQuery = redactedParams.Encode() location = parsedLocation.String() } } diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index d9345d14b..b2a22504f 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -142,13 +142,13 @@ func TestLogRequestComplete(t *testing.T) { wantAuditLogs: noAuditEventsWanted, }, { - name: "when internal paths are not Enabled, audits external path with location", + name: "when internal paths are not Enabled, audits external path with location (redacting all query params)", path: "/pretend-to-login", - location: "some-location", + location: "http://127.0.0.1?foo=bar&foo=quz&lorem=ipsum", auditCfg: supervisor.AuditSpec{ InternalPaths: "Disabled", }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "http://127.0.0.1?foo=redacted&foo=redacted&lorem=redacted"), }, { name: "when internal paths are not Enabled, audits external path without location", diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go index 752a692f9..9e491e4d9 100644 --- a/internal/plog/audit_event.go +++ b/internal/plog/audit_event.go @@ -31,19 +31,39 @@ const ( // SanitizeParams can be used to redact all params not included in the allowedKeys set. // Useful when audit logging AuditEventHTTPRequestParameters events. -func SanitizeParams(params url.Values, allowedKeys sets.Set[string]) string { - if len(params) == 0 { - return "" +func SanitizeParams(inputParams url.Values, allowedKeys sets.Set[string]) []any { + params := make(map[string]string) + multiValueParams := make(url.Values) + + transform := func(key, value string) string { + if !allowedKeys.Has(key) { + return "redacted" + } + + unescape, err := url.QueryUnescape(value) + if err != nil { + // ignore these errors and just use the original query parameter + unescape = value + } + return unescape } - sanitized := url.Values{} - for key := range params { - if allowedKeys.Has(key) { - sanitized[key] = params[key] - } else { - for range params[key] { - sanitized.Add(key, "redacted") + + for key := range inputParams { + for i, p := range inputParams[key] { + transformed := transform(key, p) + if i == 0 { + params[key] = transformed + } + + if len(inputParams[key]) > 1 { + multiValueParams[key] = append(multiValueParams[key], transformed) } } } - return sanitized.Encode() + + if len(multiValueParams) > 0 { + return []any{"params", params, "multiValueParams", multiValueParams} + + } + return []any{"params", params} } diff --git a/internal/plog/audit_event_test.go b/internal/plog/audit_event_test.go index 9cb031b6a..99658f8da 100644 --- a/internal/plog/audit_event_test.go +++ b/internal/plog/audit_event_test.go @@ -16,60 +16,156 @@ func TestSanitizeParams(t *testing.T) { name string params url.Values allowedKeys sets.Set[string] - want string + want []any }{ { name: "nil values", params: nil, allowedKeys: nil, - want: "", + want: []any{ + "params", + map[string]string{}, + }, }, { name: "empty values", params: url.Values{}, allowedKeys: nil, - want: "", + want: []any{ + "params", + map[string]string{}, + }, }, { name: "all allowed values", params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, allowedKeys: sets.New("foo", "bar"), - want: "bar=d&bar=e&bar=f&foo=a&foo=b&foo=c", + want: []any{ + "params", + map[string]string{ + "bar": "d", + "foo": "a", + }, + "multiValueParams", + url.Values{ + "bar": []string{"d", "e", "f"}, + "foo": []string{"a", "b", "c"}, + }, + }, }, { name: "all allowed values with single values", params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, allowedKeys: sets.New("foo", "bar"), - want: "bar=d&foo=a", + want: []any{ + "params", + map[string]string{ + "foo": "a", + "bar": "d", + }, + }, }, { name: "some allowed values", params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, allowedKeys: sets.New("foo"), - want: "bar=redacted&bar=redacted&bar=redacted&foo=a&foo=b&foo=c", + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a", + }, + "multiValueParams", + url.Values{ + "bar": []string{"redacted", "redacted", "redacted"}, + "foo": []string{"a", "b", "c"}, + }, + }, }, { name: "some allowed values with single values", params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, allowedKeys: sets.New("foo"), - want: "bar=redacted&foo=a", + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a", + }, + }, }, { name: "no allowed values", params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, allowedKeys: sets.New[string](), - want: "bar=redacted&bar=redacted&bar=redacted&foo=redacted&foo=redacted&foo=redacted", + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "redacted", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"redacted", "redacted", "redacted"}, + }, + }, }, { name: "nil allowed values", params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, allowedKeys: nil, - want: "bar=redacted&bar=redacted&bar=redacted&foo=redacted&foo=redacted&foo=redacted", + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "redacted", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"redacted", "redacted", "redacted"}, + }, + }, + }, + { + name: "url decodes allowed values", + params: url.Values{ + "foo": []string{"a%3Ab", "c", "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"}, + "bar": []string{"d", "e", "f"}, + }, + allowedKeys: sets.New("foo"), + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a:b", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"a:b", "c", "urn:ietf:params:oauth:grant-type:token-exchange"}, + }, + }, + }, + { + name: "ignores url decode errors", + params: url.Values{ + "bad_encoding": []string{"%.."}, + }, + allowedKeys: sets.New("bad_encoding"), + want: []any{ + "params", + map[string]string{ + "bad_encoding": "%..", + }, + }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, SanitizeParams(tt.params, tt.allowedKeys)) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // This comparison should require the exact order + require.Equal(t, test.want, SanitizeParams(test.params, test.allowedKeys)) }) } } From 088556193db15f21ad5574b3a0bed488f1321ab0 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 7 Nov 2024 14:04:36 -0800 Subject: [PATCH 22/71] auth handler audit logs headers and params when http method is wrong also refactor some related code into a helper, and fix linter errors --- .../endpoints/auth/auth_handler.go | 62 ++++++++++--------- .../endpoints/auth/auth_handler_test.go | 48 +++++++++++++- internal/plog/audit_event.go | 1 - 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index f68ea7316..19cd100f6 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -5,6 +5,7 @@ package auth import ( + "errors" "fmt" "net/http" "net/url" @@ -96,41 +97,15 @@ func NewHandler( } func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost && r.Method != http.MethodGet { - // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - // Authorization Servers MUST support the use of the HTTP GET and POST methods defined in - // RFC 2616 [RFC2616] at the Authorization Endpoint. - responseutil.HTTPErrorf(w, http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) - return - } - // If the client set a username or password header, they are trying to log in without using a browser. hadUsernameHeader := len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 hadPasswordHeader := len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 requestedBrowserlessFlow := hadUsernameHeader || hadPasswordHeader - // Need to parse the request params, so we can get the IDP name. The style and text of the error is inspired by - // fosite's implementation of NewAuthorizeRequest(). Fosite only calls ParseMultipartForm() there. However, - // although ParseMultipartForm() calls ParseForm(), it swallows errors from ParseForm() sometimes. To avoid - // having any errors swallowed, we call both. When fosite calls ParseMultipartForm() later, it will be a noop. - if err := r.ParseForm(); err != nil { + // Need to parse the request params, so we can get the IDP name and audit log the params. + if err := parseForm(r); err != nil { oidc.WriteAuthorizeError(r, w, - h.oauthHelperWithoutStorage, - fosite.NewAuthorizeRequest(), - fosite.ErrInvalidRequest. - WithHint("Unable to parse form params, make sure to send a properly formatted query params or form request body."). - WithWrap(err).WithDebug(err.Error()), - requestedBrowserlessFlow) - return - } - if err := r.ParseMultipartForm(1 << 20); err != nil && err != http.ErrNotMultipart { - oidc.WriteAuthorizeError(r, w, - h.oauthHelperWithoutStorage, - fosite.NewAuthorizeRequest(), - fosite.ErrInvalidRequest. - WithHint("Unable to parse multipart HTTP body, make sure to send a properly formatted form request body."). - WithWrap(err).WithDebug(err.Error()), - requestedBrowserlessFlow) + h.oauthHelperWithoutStorage, fosite.NewAuthorizeRequest(), err, requestedBrowserlessFlow) return } @@ -143,6 +118,14 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), plog.SanitizeParams(r.Form, paramsSafeToLog())...) + if r.Method != http.MethodPost && r.Method != http.MethodGet { + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // Authorization Servers MUST support the use of the HTTP GET and POST methods defined in + // RFC 2616 [RFC2616] at the Authorization Endpoint. + responseutil.HTTPErrorf(w, http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) + return + } + // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and // oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP. // The Pinniped CLI has been sending these params since v0.9.0. @@ -181,6 +164,27 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.authorize(w, r, requestedBrowserlessFlow, idp) } +// parseForm parses the query params and/or POST body form params. It returns an error, or in the case of success it +// has the side-effect of leaving the parsed form params on the http.Request in the Form field. Request body +// parameters take precedence over URL query string values. +func parseForm(r *http.Request) error { + // The style of form parsing and the text of the error is inspired by fosite's implementation of NewAuthorizeRequest(). + // Fosite only calls ParseMultipartForm() there. However, although ParseMultipartForm() calls ParseForm(), + // it swallows errors from ParseForm() sometimes. To avoid having any errors swallowed, we call both. + // When fosite calls ParseMultipartForm() later, it will be a noop. + if err := r.ParseForm(); err != nil { + return fosite.ErrInvalidRequest. + WithHint("Unable to parse form params, make sure to send a properly formatted query params or form request body."). + WithWrap(err).WithDebug(err.Error()) + } + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + return fosite.ErrInvalidRequest. + WithHint("Unable to parse multipart HTTP body, make sure to send a properly formatted form request body."). + WithWrap(err).WithDebug(err.Error()) + } + return nil +} + func (h *authorizeHandler) authorize( w http.ResponseWriter, r *http.Request, diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 510b0372a..7118754a5 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -3882,28 +3882,70 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo name: "PUT is a bad method", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodPut, - path: "/some/path", + path: "/some/path?foo=bar&client_id=baz", wantStatus: http.StatusMethodNotAllowed, wantContentType: plainContentType, wantBodyString: "Method Not Allowed: PUT (try GET or POST)\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "baz", + "foo": "redacted", + }, + }), + } + }, }, { name: "PATCH is a bad method", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodPatch, - path: "/some/path", + path: "/some/path?foo=bar&client_id=baz", wantStatus: http.StatusMethodNotAllowed, wantContentType: plainContentType, wantBodyString: "Method Not Allowed: PATCH (try GET or POST)\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "baz", + "foo": "redacted", + }, + }), + } + }, }, { name: "DELETE is a bad method", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodDelete, - path: "/some/path", + path: "/some/path?foo=bar&client_id=baz", wantStatus: http.StatusMethodNotAllowed, wantContentType: plainContentType, wantBodyString: "Method Not Allowed: DELETE (try GET or POST)\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "baz", + "foo": "redacted", + }, + }), + } + }, }, } diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go index 9e491e4d9..b3d2d4492 100644 --- a/internal/plog/audit_event.go +++ b/internal/plog/audit_event.go @@ -63,7 +63,6 @@ func SanitizeParams(inputParams url.Values, allowedKeys sets.Set[string]) []any if len(multiValueParams) > 0 { return []any{"params", params, "multiValueParams", multiValueParams} - } return []any{"params", params} } From 8cf9c599574828d48e1761aa8531563d9086bb56 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 7 Nov 2024 14:15:04 -0800 Subject: [PATCH 23/71] refactor to move audit event message types to their own pkg --- internal/auditevent/audit_event.go | 68 +++++++++++++++++++ .../{plog => auditevent}/audit_event_test.go | 2 +- .../supervisorstorage/garbage_collector.go | 7 +- .../downstreamsession/downstream_session.go | 7 +- .../endpoints/auth/auth_handler.go | 11 +-- .../endpoints/callback/callback_handler.go | 3 +- .../endpoints/login/login_handler.go | 3 +- .../endpoints/token/token_handler.go | 7 +- .../tokenendpointauditor/parameter_auditor.go | 5 +- .../requestlogger/request_logger.go | 5 +- internal/plog/audit_event.go | 68 ------------------- internal/plog/plog.go | 5 +- internal/registry/credentialrequest/rest.go | 3 +- 13 files changed, 102 insertions(+), 92 deletions(-) create mode 100644 internal/auditevent/audit_event.go rename internal/{plog => auditevent}/audit_event_test.go (99%) delete mode 100644 internal/plog/audit_event.go diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go new file mode 100644 index 000000000..e8b0caf48 --- /dev/null +++ b/internal/auditevent/audit_event.go @@ -0,0 +1,68 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auditevent + +import ( + "net/url" + + "k8s.io/apimachinery/pkg/util/sets" +) + +type Message string + +const ( + HTTPRequestReceived Message = "HTTP Request Received" + HTTPRequestCompleted Message = "HTTP Request Completed" + HTTPRequestParameters Message = "HTTP Request Parameters" + HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" + UsingUpstreamIDP Message = "Using Upstream IDP" + AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" + IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" + IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" + SessionStarted Message = "Session Started" + SessionRefreshed Message = "Session Refreshed" + AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" + UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential + SessionGarbageCollected Message = "Session Garbage Collected" + TokenCredentialRequest Message = "TokenCredentialRequest" //nolint:gosec // this is not a credential + UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" +) + +// SanitizeParams can be used to redact all params not included in the allowedKeys set. +// Useful when audit logging HTTPRequestParameters events. +func SanitizeParams(inputParams url.Values, allowedKeys sets.Set[string]) []any { + params := make(map[string]string) + multiValueParams := make(url.Values) + + transform := func(key, value string) string { + if !allowedKeys.Has(key) { + return "redacted" + } + + unescape, err := url.QueryUnescape(value) + if err != nil { + // ignore these errors and just use the original query parameter + unescape = value + } + return unescape + } + + for key := range inputParams { + for i, p := range inputParams[key] { + transformed := transform(key, p) + if i == 0 { + params[key] = transformed + } + + if len(inputParams[key]) > 1 { + multiValueParams[key] = append(multiValueParams[key], transformed) + } + } + } + + if len(multiValueParams) > 0 { + return []any{"params", params, "multiValueParams", multiValueParams} + } + return []any{"params", params} +} diff --git a/internal/plog/audit_event_test.go b/internal/auditevent/audit_event_test.go similarity index 99% rename from internal/plog/audit_event_test.go rename to internal/auditevent/audit_event_test.go index 99658f8da..51924710c 100644 --- a/internal/plog/audit_event_test.go +++ b/internal/auditevent/audit_event_test.go @@ -1,7 +1,7 @@ // Copyright 2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package plog +package auditevent import ( "net/url" diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 4e92f1da8..3f0eb4bbd 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -19,6 +19,7 @@ import ( clocktesting "k8s.io/utils/clock/testing" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/auditevent" pinnipedcontroller "go.pinniped.dev/internal/controller" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/crud" @@ -283,7 +284,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, + c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, "type", upstreamprovider.RefreshTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC refresh token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -293,7 +294,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(plog.AuditEventUpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, + c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, "type", upstreamprovider.AccessTokenType) plog.Trace("garbage collector successfully revoked upstream OIDC access token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -304,7 +305,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( func (c *garbageCollectorController) maybeAuditLogGC(storageType string, secret *corev1.Secret) { r, err := c.requestFromSecret(storageType, secret) if err == nil && r != nil { - c.auditLogger.Audit(plog.AuditEventSessionGarbageCollected, plog.NoHTTPRequestAvailable(), r, + c.auditLogger.Audit(auditevent.SessionGarbageCollected, plog.NoHTTPRequestAvailable(), r, "storageType", storageType) } } diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 7dd1d377e..8a4bcb4d8 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -15,6 +15,7 @@ import ( fositejwt "github.com/ory/fosite/token/jwt" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/resolvedprovider" @@ -49,7 +50,7 @@ func NewPinnipedSession( ) (*psession.PinnipedSession, error) { now := time.Now().UTC() - auditLogger.Audit(plog.AuditEventIdentityFromUpstreamIDP, ctx, plog.NoSessionPersisted(), + auditLogger.Audit(auditevent.IdentityFromUpstreamIDP, ctx, plog.NoSessionPersisted(), "upstreamIDPDisplayName", c.IdentityProvider.GetDisplayName(), "upstreamIDPType", c.IdentityProvider.GetSessionProviderType(), "upstreamIDPResourceName", c.IdentityProvider.GetProvider().GetResourceName(), @@ -60,7 +61,7 @@ func NewPinnipedSession( downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { - auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, plog.NoSessionPersisted(), + auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, ctx, plog.NoSessionPersisted(), "reason", err) return nil, err } @@ -108,7 +109,7 @@ func NewPinnipedSession( pinnipedSession.IDTokenClaims().Extra = extras - auditLogger.Audit(plog.AuditEventSessionStarted, ctx, c.SessionIDGetter, + auditLogger.Audit(auditevent.SessionStarted, ctx, c.SessionIDGetter, "username", downstreamUsername, "groups", downstreamGroups, "subject", c.UpstreamIdentity.DownstreamSubject, diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 19cd100f6..a046ccc75 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -17,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/util/sets" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/downstreamsession" "go.pinniped.dev/internal/federationdomain/federationdomainproviders" @@ -111,12 +112,12 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Log if these headers were present, but don't log the actual values. The password is obviously sensitive, // and sometimes users use their password as their username by mistake. - h.auditLogger.Audit(plog.AuditEventHTTPRequestCustomHeadersUsed, r.Context(), plog.NoSessionPersisted(), + h.auditLogger.Audit(auditevent.HTTPRequestCustomHeadersUsed, r.Context(), plog.NoSessionPersisted(), oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader, oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) - h.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), - plog.SanitizeParams(r.Form, paramsSafeToLog())...) + h.auditLogger.Audit(auditevent.HTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), + auditevent.SanitizeParams(r.Form, paramsSafeToLog())...) if r.Method != http.MethodPost && r.Method != http.MethodGet { // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest @@ -155,7 +156,7 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.auditLogger.Audit(plog.AuditEventUsingUpstreamIDP, r.Context(), plog.NoSessionPersisted(), + h.auditLogger.Audit(auditevent.UsingUpstreamIDP, r.Context(), plog.NoSessionPersisted(), "displayName", idp.GetDisplayName(), "resourceName", idp.GetProvider().GetResourceName(), "resourceUID", idp.GetProvider().GetResourceUID(), @@ -220,7 +221,7 @@ func (h *authorizeHandler) authorize( authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) if err == nil { - h.auditLogger.Audit(plog.AuditEventUpstreamAuthorizeRedirect, r.Context(), plog.NoSessionPersisted(), + h.auditLogger.Audit(auditevent.UpstreamAuthorizeRedirect, r.Context(), plog.NoSessionPersisted(), "authorizeID", authorizeID) } } diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 60853f6b1..8078e530b 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -10,6 +10,7 @@ import ( "github.com/ory/fosite" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/downstreamsession" "go.pinniped.dev/internal/federationdomain/federationdomainproviders" "go.pinniped.dev/internal/federationdomain/formposthtml" @@ -33,7 +34,7 @@ func NewHandler( return err } - auditLogger.Audit(plog.AuditEventAuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), + auditLogger.Audit(auditevent.AuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), "authorizeID", encodedState.AuthorizeID()) idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(decodedState.UpstreamName) diff --git a/internal/federationdomain/endpoints/login/login_handler.go b/internal/federationdomain/endpoints/login/login_handler.go index e37c6d40b..d1a4c4126 100644 --- a/internal/federationdomain/endpoints/login/login_handler.go +++ b/internal/federationdomain/endpoints/login/login_handler.go @@ -7,6 +7,7 @@ import ( "net/http" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/oidc" @@ -58,7 +59,7 @@ func NewHandler( return err } - auditLogger.Audit(plog.AuditEventAuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), + auditLogger.Audit(auditevent.AuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), "authorizeID", encodedState.AuthorizeID()) switch decodedState.UpstreamType { diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index e50811748..e701be106 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -19,6 +19,7 @@ import ( "k8s.io/apiserver/pkg/warning" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/federationdomainproviders" "go.pinniped.dev/internal/federationdomain/idtokenlifespan" "go.pinniped.dev/internal/federationdomain/oidc" @@ -191,7 +192,7 @@ func upstreamRefresh( return err } - auditLogger.Audit(plog.AuditEventIdentityRefreshedFromUpstreamIDP, ctx, accessRequest, + auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, ctx, accessRequest, "upstreamUsername", refreshedIdentity.UpstreamUsername, "upstreamGroups", refreshedIdentity.UpstreamGroups) @@ -220,7 +221,7 @@ func upstreamRefresh( if fositeErr != nil { // The HintField is always populated by applyIdentityTransformationsDuringRefresh, // and more descriptive than fositeErr.Error() which is just "error". - auditLogger.Audit(plog.AuditEventAuthenticationRejectedByTransforms, ctx, accessRequest, + auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, ctx, accessRequest, "reason", fositeErr.HintField) return fositeErr } @@ -238,7 +239,7 @@ func upstreamRefresh( session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedTransformedGroups } - auditLogger.Audit(plog.AuditEventSessionRefreshed, ctx, accessRequest, + auditLogger.Audit(auditevent.SessionRefreshed, ctx, accessRequest, "username", oldTransformedUsername, // not allowed to change above so must be the same as old "groups", refreshedTransformedGroups, "subject", previousIdentity.DownstreamSubject) diff --git a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go index 37f5413c3..011949f91 100644 --- a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go +++ b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go @@ -10,6 +10,7 @@ import ( "github.com/ory/fosite/compose" "k8s.io/apimachinery/pkg/util/sets" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/plog" ) @@ -52,8 +53,8 @@ func paramsSafeToLogTokenEndpoint() sets.Set[string] { } func (p parameterAuditorHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { - p.auditLogger.Audit(plog.AuditEventHTTPRequestParameters, ctx, plog.NoSessionPersisted(), - plog.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())...) + p.auditLogger.Audit(auditevent.HTTPRequestParameters, ctx, plog.NoSessionPersisted(), + auditevent.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())...) return false } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 11e2400eb..e2f9e74d7 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -18,6 +18,7 @@ import ( "k8s.io/apiserver/pkg/endpoints/responsewriter" "k8s.io/utils/clock" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/httputil/requestutil" "go.pinniped.dev/internal/plog" @@ -102,7 +103,7 @@ func (rl *requestLogger) logRequestReceived() { } // Always log all other requests, including 404's caused by bad paths, for debugging purposes. - rl.auditLogger.Audit(plog.AuditEventHTTPRequestReceived, + rl.auditLogger.Audit(auditevent.HTTPRequestReceived, r.Context(), plog.NoSessionPersisted(), "proto", r.Proto, @@ -142,7 +143,7 @@ func (rl *requestLogger) logRequestComplete() { } } - rl.auditLogger.Audit(plog.AuditEventHTTPRequestCompleted, + rl.auditLogger.Audit(auditevent.HTTPRequestCompleted, r.Context(), plog.NoSessionPersisted(), "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events diff --git a/internal/plog/audit_event.go b/internal/plog/audit_event.go deleted file mode 100644 index b3d2d4492..000000000 --- a/internal/plog/audit_event.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2024 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package plog - -import ( - "net/url" - - "k8s.io/apimachinery/pkg/util/sets" -) - -type AuditEventMessage string - -const ( - AuditEventHTTPRequestReceived AuditEventMessage = "HTTP Request Received" - AuditEventHTTPRequestCompleted AuditEventMessage = "HTTP Request Completed" - AuditEventHTTPRequestParameters AuditEventMessage = "HTTP Request Parameters" - AuditEventHTTPRequestCustomHeadersUsed AuditEventMessage = "HTTP Request Custom Headers Used" - AuditEventUsingUpstreamIDP AuditEventMessage = "Using Upstream IDP" - AuditEventAuthorizeIDFromParameters AuditEventMessage = "AuthorizeID From Parameters" - AuditEventIdentityFromUpstreamIDP AuditEventMessage = "Identity From Upstream IDP" - AuditEventIdentityRefreshedFromUpstreamIDP AuditEventMessage = "Identity Refreshed From Upstream IDP" - AuditEventSessionStarted AuditEventMessage = "Session Started" - AuditEventSessionRefreshed AuditEventMessage = "Session Refreshed" - AuditEventAuthenticationRejectedByTransforms AuditEventMessage = "Authentication Rejected By Transforms" - AuditEventUpstreamOIDCTokenRevoked AuditEventMessage = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential - AuditEventSessionGarbageCollected AuditEventMessage = "Session Garbage Collected" - AuditEventTokenCredentialRequest AuditEventMessage = "TokenCredentialRequest" //nolint:gosec // this is not a credential - AuditEventUpstreamAuthorizeRedirect AuditEventMessage = "Upstream Authorize Redirect" -) - -// SanitizeParams can be used to redact all params not included in the allowedKeys set. -// Useful when audit logging AuditEventHTTPRequestParameters events. -func SanitizeParams(inputParams url.Values, allowedKeys sets.Set[string]) []any { - params := make(map[string]string) - multiValueParams := make(url.Values) - - transform := func(key, value string) string { - if !allowedKeys.Has(key) { - return "redacted" - } - - unescape, err := url.QueryUnescape(value) - if err != nil { - // ignore these errors and just use the original query parameter - unescape = value - } - return unescape - } - - for key := range inputParams { - for i, p := range inputParams[key] { - transformed := transform(key, p) - if i == 0 { - params[key] = transformed - } - - if len(inputParams[key]) > 1 { - multiValueParams[key] = append(multiValueParams[key], transformed) - } - } - } - - if len(multiValueParams) > 0 { - return []any{"params", params, "multiValueParams", multiValueParams} - } - return []any{"params", params} -} diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 81e9893da..8768071e4 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -33,6 +33,7 @@ import ( "slices" "github.com/go-logr/logr" + "go.pinniped.dev/internal/auditevent" "k8s.io/apiserver/pkg/audit" ) @@ -61,7 +62,7 @@ type AuditLogger interface { // reqCtx and session may be null. // When possible, pass the http request's context as reqCtx, so we may read the audit ID from the context. // When possible, pass the fosite.Requester or fosite.Request as the session, so we can log the session ID. - Audit(msg AuditEventMessage, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) + Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) } // Logger implements the plog logging convention described above. The global functions in this package @@ -126,7 +127,7 @@ func (p pLogger) Error(msg string, err error, keysAndValues ...any) { // by their own separate configuration. This is because Audit logs should always be printed when they are desired // by the admin, regardless of global log level, yet the admin should also have a way to entirely disable them // when they want to avoid potential PII (e.g. usernames) in their pod logs. -func (p pLogger) Audit(msg AuditEventMessage, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { +func (p pLogger) Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { // Always add a key/value auditEvent=true. keysAndValues = slices.Concat([]any{"auditEvent", true}, keysAndValues) diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 447a5500c..2ca645ce1 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -21,6 +21,7 @@ import ( "k8s.io/utils/trace" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/plog" ) @@ -131,7 +132,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation traceSuccess(t, userInfo, true) - r.auditLogger.Audit(plog.AuditEventTokenCredentialRequest, ctx, nil, + r.auditLogger.Audit(auditevent.TokenCredentialRequest, ctx, nil, "username", userInfo.GetName(), "groups", userInfo.GetGroups(), "authenticated", true, From 2db5dda26621a002d80ad37b45b347e10045b8e0 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 8 Nov 2024 15:28:52 -0600 Subject: [PATCH 24/71] Add last audit log unit tests to auth_handler Co-authored-by: Ryan Richard --- .../endpoints/auth/auth_handler_test.go | 111 +++++++++++++++++- 1 file changed, 106 insertions(+), 5 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 7118754a5..313c63123 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -1764,6 +1764,49 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-active-directory-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, + }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-active-directory-idp", + "resourceName": "some-active-directory-idp", + "resourceUID": "active-directory-resource-uid", + "type": "activedirectory", + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-active-directory-idp", + "upstreamIDPResourceName": "some-active-directory-idp", + "upstreamIDPResourceUID": "active-directory-resource-uid", + "upstreamIDPType": "activedirectory", + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "username": "some-ldap-username-from-authenticator", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-active-directory-idp&sub=some-ldap-uid", + "additionalClaims": nil, // json: null + "warnings": []any{}, // json: [] + }), + } + }, }, { name: "OIDC upstream browser flow happy path with prompt param other than none that gets ignored", @@ -2177,6 +2220,33 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": true, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-password-granting-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, + }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-password-granting-oidc-idp", + "resourceName": "some-password-granting-oidc-idp", + "resourceUID": "some-password-granting-resource-uid", + "type": "oidc", + }), + } + }, }, { name: "missing upstream username but has password on request for LDAP authentication", @@ -4120,10 +4190,8 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo }) } - t.Run("allows upstream provider configuration to change between requests", func(t *testing.T) { + t.Run("allows upstream provider in-memory cache to change between requests", func(t *testing.T) { test := tests[0] - // TODO: check to see if it's easy to verify audit logs - test.wantAuditLogs = nil // Double-check that we are re-using the happy path test case here as we intend. require.Equal(t, "OIDC upstream browser flow happy path using GET without a CSRF cookie", test.name) @@ -4145,20 +4213,23 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo ) runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) + auditLog.Reset() // clear the log for the next authorize call // Call the idpLister's setter to change the upstream IDP settings. newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName("some-other-new-idp-name"). + WithDisplayNameForFederationDomain("some-other-new-idp-display-name"). WithClientID("some-other-new-client-id"). WithAuthorizationURL(*upstreamAuthURL). WithScopes([]string{"some-other-new-scope1", "some-other-new-scope2"}). WithAdditionalAuthcodeParams(map[string]string{"prompt": "consent", "abc": "123"}). + WithResourceUID("some-other-new-resource-id"). Build() idpLister.SetOIDCIdentityProviders([]*oidctestutil.TestUpstreamOIDCIdentityProvider{newProviderSettings}) test.path = modifiedHappyGetRequestPath(map[string]string{ // update the IDP name in the request to match the name of the new IDP - "pinniped_idp_name": "some-other-new-idp-name", + "pinniped_idp_name": "some-other-new-idp-display-name", }) // Update the expectations of the test case to match the new upstream IDP settings. @@ -4170,7 +4241,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "scope": "some-other-new-scope1 some-other-new-scope2", // updated expectation "client_id": "some-other-new-client-id", // updated expectation "state": expectedUpstreamStateParam( - nil, "", "some-other-new-idp-name", "oidc", + nil, "", "some-other-new-idp-display-name", "oidc", ), // updated expectation "nonce": happyNonce, "code_challenge": expectedUpstreamCodeChallenge, @@ -4182,6 +4253,36 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo html.EscapeString(test.wantLocationHeader), "\n\n", ) + test.wantAuditLogs = func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-other-new-idp-display-name", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, + }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-other-new-idp-display-name", + "resourceName": "some-other-new-idp-name", + "resourceUID": "some-other-new-resource-id", + "type": "oidc", + }), + testutil.WantAuditLog("Upstream Authorize Redirect", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + } // Run again on the same instance of the subject with the modified upstream IDP settings and the // modified expectations. This should ensure that the implementation is using the in-memory cache From f4f393e5de1cdf19c5eeecc9a4156f0a414221ab Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 8 Nov 2024 15:36:04 -0600 Subject: [PATCH 25/71] Audit event 'HTTP Request Completed' will now log the location with err, error, and error_description query parameters Co-authored-by: Ryan Richard --- .../requestlogger/request_logger.go | 8 ++++ .../requestlogger/request_logger_test.go | 45 +++++++++---------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index e2f9e74d7..4ea6fdbeb 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -134,6 +134,14 @@ func (rl *requestLogger) logRequestComplete() { // We don't know what this `Location` header is used for, so redact all query params redactedParams := parsedLocation.Query() for k, v := range redactedParams { + // Due to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, + // authorize errors can have an 'error' and an 'error_description' parameter + // which should never contain PII and is safe to log. + // The 'err' parameter may be populated by the post_login_handler to indicate issues + // when using Supervisor's built-in login page. + if k == "error" || k == "error_description" || k == "err" { + continue + } for i := range v { redactedParams[k][i] = "redacted" } diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index b2a22504f..37bf208b9 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -142,7 +142,7 @@ func TestLogRequestComplete(t *testing.T) { wantAuditLogs: noAuditEventsWanted, }, { - name: "when internal paths are not Enabled, audits external path with location (redacting all query params)", + name: "when internal paths are not Enabled, audits external path with location (redacting unknown query params)", path: "/pretend-to-login", location: "http://127.0.0.1?foo=bar&foo=quz&lorem=ipsum", auditCfg: supervisor.AuditSpec{ @@ -151,31 +151,12 @@ func TestLogRequestComplete(t *testing.T) { wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "http://127.0.0.1?foo=redacted&foo=redacted&lorem=redacted"), }, { - name: "when internal paths are not Enabled, audits external path without location", - path: "/pretend-to-login", - location: "", // make it obvious - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "no location header"), - }, - { - name: "when internal paths are not Enabled, audits external path with invalid location", - path: "/pretend-to-login", - location: "http://e x a m p l e.com", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "unparsable location header"), - }, - { - name: "when internal paths are Enabled, audits internal paths", - path: "/healthz", - location: "some-location", + name: "when internal paths are Enabled, audits internal paths", + path: "/healthz", auditCfg: supervisor.AuditSpec{ InternalPaths: "Enabled", }, - wantAuditLogs: happyAuditEventWanted("/healthz", "some-location"), + wantAuditLogs: happyAuditEventWanted("/healthz", ""), }, { name: "when internal paths are Enabled, audits external paths", @@ -186,6 +167,24 @@ func TestLogRequestComplete(t *testing.T) { }, wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), }, + { + name: "audits path without location", + path: "/pretend-to-login", + location: "", // make it obvious + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "no location header"), + }, + { + name: "audits path with invalid location", + path: "/pretend-to-login", + location: "http://e x a m p l e.com", + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "unparsable location header"), + }, + { + name: "audits path with location redacting all query params except err, error, and error_description", + path: "/pretend-to-login", + location: "http://127.0.0.1:1234?code=pin_ac_FAKE&foo=bar&foo=quz&lorem=ipsum&err=some-err&error=some-error&error_description=some-error-description&zzlast=some-value", + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "http://127.0.0.1:1234?code=redacted&err=some-err&error=some-error&error_description=some-error-description&foo=redacted&foo=redacted&lorem=redacted&zzlast=redacted"), + }, } nowDoesntMatter := time.Date(1122, time.September, 33, 4, 55, 56, 778899, time.Local) From f9e1dd4bec3e2e2f9de06d9b6f0b12037e078b1d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 11 Nov 2024 10:13:07 -0600 Subject: [PATCH 26/71] Backfill unit tests for garbage_collector audit logging --- .../supervisorstorage/garbage_collector.go | 7 + .../garbage_collector_test.go | 159 +++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 3f0eb4bbd..7f64cf527 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "slices" + "strings" "time" "github.com/ory/fosite" @@ -114,6 +116,11 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { return err } + // Sort secrets by name so that audit log tests are deterministic + slices.SortStableFunc(listOfSecrets, func(a, b *corev1.Secret) int { + return strings.Compare(a.ObjectMeta.Name, b.ObjectMeta.Name) + }) + for i := range listOfSecrets { secret := listOfSecrets[i] diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index 436819e15..b879233c2 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -4,6 +4,7 @@ package supervisorstorage import ( + "bytes" "context" "encoding/json" "errors" @@ -138,19 +139,23 @@ func TestGarbageCollectorControllerSync(t *testing.T) { syncContext *controllerlib.Context fakeClock *clocktesting.FakeClock frozenNow time.Time + auditLog *bytes.Buffer + wantAuditLogs []testutil.WantedAuditLog ) // Defer starting the informers until the last possible moment so that the // nested Before's can keep adding things to the informer caches. var startInformersAndController = func(idpCache dynamicupstreamprovider.DynamicUpstreamIDPProvider) { // Set this at the last second to allow for injection of server override. + var auditLogger plog.AuditLogger + auditLogger, auditLog = plog.TestLogger(t) subject = GarbageCollectorController( idpCache, fakeClock, kubeClient, kubeInformers.Core().V1().Secrets(), controllerlib.WithInformer, - plog.New(), + auditLogger, ) // Set this at the last second to support calling subject.Name(). @@ -192,6 +197,8 @@ func TestGarbageCollectorControllerSync(t *testing.T) { it.After(func() { cancelContextCancelFunc() + + testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) }) when("there are secrets without the garbage-collect-after annotation", func() { @@ -387,6 +394,27 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-1", + "type": "refresh_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-2", + "storageType": "authcode", + }, + ), + } }) }) @@ -511,6 +539,27 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-1", + "type": "access_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-2", + "storageType": "authcode", + }, + ), + } }) }) @@ -651,6 +700,15 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + } }) }) @@ -722,6 +780,15 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + } }) }) @@ -827,6 +894,15 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + } }) }) @@ -906,6 +982,15 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "authcode", + }, + ), + } }) }) @@ -1030,6 +1115,27 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "access-token", + }, + ), + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-2", + "type": "refresh_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-2", + "storageType": "access-token", + }, + ), + } }) }) @@ -1154,6 +1260,27 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "access-token", + }, + ), + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-2", + "type": "access_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-2", + "storageType": "access-token", + }, + ), + } }) }) @@ -1231,6 +1358,21 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-1", + "type": "refresh_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "refresh-token", + }, + ), + } }) }) @@ -1308,6 +1450,21 @@ func TestGarbageCollectorControllerSync(t *testing.T) { }, kubeClient.Actions(), ) + + wantAuditLogs = []testutil.WantedAuditLog{ + testutil.WantAuditLog("Upstream OIDC Token Revoked", + map[string]any{ + "sessionID": "request-id-1", + "type": "access_token", + }, + ), + testutil.WantAuditLog("Session Garbage Collected", + map[string]any{ + "sessionID": "request-id-1", + "storageType": "refresh-token", + }, + ), + } }) }) From 76f6b725b89e7dcf7a289bdc473f670c0c2d2b7e Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 11 Nov 2024 10:33:01 -0600 Subject: [PATCH 27/71] Fix some rebase conflicts --- .../requestlogger/request_logger.go | 59 ++++++++++--------- .../requestlogger/request_logger_test.go | 2 +- internal/plog/plog.go | 3 +- internal/plog/plog_test.go | 1 - 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 4ea6fdbeb..899ebceda 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -116,6 +116,35 @@ func (rl *requestLogger) logRequestReceived() { ) } +func getLocationForAuditLogs(location string) string { + if location == "" { + return "no location header" + } + + parsedLocation, err := url.Parse(location) + if err != nil { + return "unparsable location header" + } + + // We don't know what this `Location` header is used for, so redact nearly all query parameters + redactedParams := parsedLocation.Query() + for k, v := range redactedParams { + // Due to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, + // authorize errors can have an 'error' and an 'error_description' parameter + // which should never contain PII and is safe to log. + // The 'err' parameter may be populated by the post_login_handler to indicate issues + // when using Supervisor's built-in login page. + if k == "error" || k == "error_description" || k == "err" { + continue + } + for i := range v { + redactedParams[k][i] = "redacted" + } + } + parsedLocation.RawQuery = redactedParams.Encode() + return parsedLocation.String() +} + func (rl *requestLogger) logRequestComplete() { r := rl.req @@ -123,41 +152,13 @@ func (rl *requestLogger) logRequestComplete() { return } - location := rl.Header().Get("Location") - if location == "" { - location = "no location header" - } else { - parsedLocation, err := url.Parse(location) - if err != nil { - location = "unparsable location header" - } else { - // We don't know what this `Location` header is used for, so redact all query params - redactedParams := parsedLocation.Query() - for k, v := range redactedParams { - // Due to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, - // authorize errors can have an 'error' and an 'error_description' parameter - // which should never contain PII and is safe to log. - // The 'err' parameter may be populated by the post_login_handler to indicate issues - // when using Supervisor's built-in login page. - if k == "error" || k == "error_description" || k == "err" { - continue - } - for i := range v { - redactedParams[k][i] = "redacted" - } - } - parsedLocation.RawQuery = redactedParams.Encode() - location = parsedLocation.String() - } - } - rl.auditLogger.Audit(auditevent.HTTPRequestCompleted, r.Context(), plog.NoSessionPersisted(), "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events "latency", rl.clock.Since(rl.startTime), "responseStatus", rl.status, - "location", location, + "location", getLocationForAuditLogs(rl.Header().Get("Location")), ) } diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index 37bf208b9..1ad3e2653 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -156,7 +156,7 @@ func TestLogRequestComplete(t *testing.T) { auditCfg: supervisor.AuditSpec{ InternalPaths: "Enabled", }, - wantAuditLogs: happyAuditEventWanted("/healthz", ""), + wantAuditLogs: happyAuditEventWanted("/healthz", "no location header"), }, { name: "when internal paths are Enabled, audits external paths", diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 8768071e4..d78022d10 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -33,8 +33,9 @@ import ( "slices" "github.com/go-logr/logr" - "go.pinniped.dev/internal/auditevent" "k8s.io/apiserver/pkg/audit" + + "go.pinniped.dev/internal/auditevent" ) const errorKey = "error" // this matches zapr's default for .Error calls (which is asserted via tests) diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index c2df44c7f..894a28c22 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -4,7 +4,6 @@ package plog import ( - "bytes" "fmt" "runtime" "strings" From ced8686d1121eb38a277c34f40f78f5db8ea25af Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 11 Nov 2024 15:21:14 -0800 Subject: [PATCH 28/71] add config for audit logging, remove Audit() from Logger interface Co-authored-by: Joshua Casey --- deploy/concierge/deployment.yaml | 2 + deploy/concierge/values.yaml | 12 ++ deploy/supervisor/helpers.lib.yaml | 4 + deploy/supervisor/values.yaml | 20 +++ internal/concierge/apiserver/apiserver.go | 3 +- internal/concierge/server/server.go | 42 +++--- internal/config/concierge/config.go | 12 ++ internal/config/concierge/config_test.go | 33 +++++ internal/config/concierge/types.go | 16 +++ internal/config/supervisor/config.go | 24 ++++ internal/config/supervisor/config_test.go | 65 +++++++++ internal/config/supervisor/types.go | 16 ++- .../garbage_collector_test.go | 8 +- .../endpoints/auth/auth_handler_test.go | 16 +-- .../callback/callback_handler_test.go | 6 +- .../endpoints/login/login_handler_test.go | 6 +- .../login/post_login_handler_test.go | 4 +- .../endpoints/token/token_handler_test.go | 6 +- .../endpointsmanager/manager.go | 8 +- .../endpointsmanager/manager_test.go | 6 +- .../requestlogger/request_logger.go | 34 +++-- .../requestlogger/request_logger_test.go | 125 ++++++++---------- internal/plog/plog.go | 51 ++++--- internal/plog/testing.go | 7 + .../registry/credentialrequest/rest_test.go | 32 ++--- internal/supervisor/server/server.go | 12 +- 26 files changed, 405 insertions(+), 165 deletions(-) diff --git a/deploy/concierge/deployment.yaml b/deploy/concierge/deployment.yaml index 6aac1fd1e..f6f0b37e5 100644 --- a/deploy/concierge/deployment.yaml +++ b/deploy/concierge/deployment.yaml @@ -103,6 +103,8 @@ data: tls: onedottwo: allowedCiphers: (@= str(data.values.allowed_ciphers_for_tls_onedottwo) @) + audit: + logUsernamesAndGroups: (@= data.values.audit.log_usernames_and_groups @) --- #@ if data.values.image_pull_dockerconfigjson and data.values.image_pull_dockerconfigjson != "": apiVersion: v1 diff --git a/deploy/concierge/values.yaml b/deploy/concierge/values.yaml index 24ccea66a..e18b1cc8c 100644 --- a/deploy/concierge/values.yaml +++ b/deploy/concierge/values.yaml @@ -231,3 +231,15 @@ no_proxy: "$(KUBERNETES_SERVICE_HOST),169.254.169.254,127.0.0.1,localhost,.svc,. #! An empty array is perfectly valid, as is any array of strings. allowed_ciphers_for_tls_onedottwo: - "" + +#@schema/title "Audit logging configuration" +#@schema/desc "Customize the content of audit log events." +audit: + + #@schema/title "Log usernames and groups" + #@ log_usernames_and_groups_desc = "Enables or disables printing usernames and group names in audit logs. Options are 'enabled' or 'disabled'. \ + #@ If enabled, usernames are group names may be printed in audit log events. \ + #@ If disabled, usernames and group names will be redacted from audit logs because they might contain personally identifiable information." + #@schema/desc log_usernames_and_groups_desc + #@schema/validation one_of=["enabled", "disabled"] + log_usernames_and_groups: disabled diff --git a/deploy/supervisor/helpers.lib.yaml b/deploy/supervisor/helpers.lib.yaml index 693dedaa1..cf3d3d55b 100644 --- a/deploy/supervisor/helpers.lib.yaml +++ b/deploy/supervisor/helpers.lib.yaml @@ -57,6 +57,10 @@ _: #@ template.replace(data.values.custom_labels) #@ "onedottwo": { #@ "allowedCiphers": data.values.allowed_ciphers_for_tls_onedottwo #@ } +#@ }, +#@ "audit": { +#@ "logUsernamesAndGroups": data.values.audit.log_usernames_and_groups, +#@ "logInternalPaths": data.values.audit.log_internal_paths #@ } #@ } #@ if data.values.log_level: diff --git a/deploy/supervisor/values.yaml b/deploy/supervisor/values.yaml index f4aab5e62..7badb9e4f 100644 --- a/deploy/supervisor/values.yaml +++ b/deploy/supervisor/values.yaml @@ -220,3 +220,23 @@ endpoints: { } #! An empty array is perfectly valid, as is any array of strings. allowed_ciphers_for_tls_onedottwo: - "" + +#@schema/title "Audit logging configuration" +#@schema/desc "Customize the content of audit log events." +audit: + + #@schema/title "Log usernames and groups" + #@ log_usernames_and_groups_desc = "Enables or disables printing usernames and group names in audit logs. Options are 'enabled' or 'disabled'. \ + #@ If enabled, usernames are group names may be printed in audit log events. \ + #@ If disabled, usernames and group names will be redacted from audit logs because they might contain personally identifiable information." + #@schema/desc log_usernames_and_groups_desc + #@schema/validation one_of=["enabled", "disabled"] + log_usernames_and_groups: disabled + + #@schema/title "Log HTTPS requests for internal paths" + #@ log_internal_paths = "Enables or disables request logging for internal paths in audit logs. Options are 'enabled' or 'disabled'. \ + #@ If enabled, requests to certain paths that are typically only used internal to the cluster (e.g. /healthz) will be enabled, which can be very verbose. \ + #@ If disabled, requests to those paths will not be audit logged." + #@schema/desc log_internal_paths + #@schema/validation one_of=["enabled", "disabled"] + log_internal_paths: disabled diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index 184b5e00b..a147efb8e 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -39,6 +39,7 @@ type ExtraConfig struct { LoginConciergeGroupVersion schema.GroupVersion IdentityConciergeGroupVersion schema.GroupVersion TokenClient *tokenclient.TokenClient + AuditLogger plog.AuditLogger } type PinnipedServer struct { @@ -82,7 +83,7 @@ func (c completedConfig) New() (*PinnipedServer, error) { for _, f := range []func() (schema.GroupVersionResource, rest.Storage){ func() (schema.GroupVersionResource, rest.Storage) { tokenCredReqGVR := c.ExtraConfig.LoginConciergeGroupVersion.WithResource("tokencredentialrequests") - tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource(), plog.New()) + tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource(), c.ExtraConfig.AuditLogger) return tokenCredReqGVR, tokenCredStorage }, func() (schema.GroupVersionResource, rest.Storage) { diff --git a/internal/concierge/server/server.go b/internal/concierge/server/server.go index f6648336c..67629c6ab 100644 --- a/internal/concierge/server/server.go +++ b/internal/concierge/server/server.go @@ -180,21 +180,9 @@ func (a *App) runServer(ctx context.Context) error { dynamiccertauthority.New(impersonationProxySigningCertProvider), // fallback to our internal CA if we need to } - // Get the aggregated API server config. - aggregatedAPIServerConfig, err := getAggregatedAPIServerConfig( - dynamicServingCertProvider, - authenticators, - certIssuer, - buildControllers, - *cfg.APIGroupSuffix, - *cfg.AggregatedAPIServerPort, - scheme, - loginGV, - identityGV, - ) - if err != nil { - return fmt.Errorf("could not configure aggregated API server: %w", err) - } + auditLogger := plog.NewAuditLogger(plog.AuditLogConfig{ + LogUsernamesAndGroupNames: cfg.Audit.LogUsernamesAndGroups.Enabled(), + }) // Configure a token client that retrieves relatively short-lived tokens from the API server. // It uses a k8s client without leader election because all pods need tokens. @@ -206,13 +194,31 @@ func (a *App) runServer(ctx context.Context) error { if err != nil { return fmt.Errorf("could not create default kubernetes client: %w", err) } - aggregatedAPIServerConfig.ExtraConfig.TokenClient = tokenclient.New( + tokenClient := tokenclient.New( cfg.NamesConfig.ImpersonationProxyServiceAccount, k8sClient.Kubernetes.CoreV1().ServiceAccounts(podInfo.Namespace), impersonationProxyTokenCache.Set, plog.New(), tokenclient.WithExpirationSeconds(oneDayInSeconds)) + // Get the aggregated API server config. + aggregatedAPIServerConfig, err := getAggregatedAPIServerConfig( + dynamicServingCertProvider, + authenticators, + certIssuer, + buildControllers, + *cfg.APIGroupSuffix, + *cfg.AggregatedAPIServerPort, + scheme, + loginGV, + identityGV, + auditLogger, + tokenClient, + ) + if err != nil { + return fmt.Errorf("could not configure aggregated API server: %w", err) + } + // Complete the aggregated API server config and make a server instance. server, err := aggregatedAPIServerConfig.Complete().New() if err != nil { @@ -235,6 +241,8 @@ func getAggregatedAPIServerConfig( aggregatedAPIServerPort int64, scheme *runtime.Scheme, loginConciergeGroupVersion, identityConciergeGroupVersion schema.GroupVersion, + auditLogger plog.AuditLogger, + tokenClient *tokenclient.TokenClient, ) (*apiserver.Config, error) { codecs := serializer.NewCodecFactory(scheme) @@ -301,6 +309,8 @@ func getAggregatedAPIServerConfig( NegotiatedSerializer: codecs, LoginConciergeGroupVersion: loginConciergeGroupVersion, IdentityConciergeGroupVersion: identityConciergeGroupVersion, + TokenClient: tokenClient, + AuditLogger: auditLogger, }, } return apiServerConfig, nil diff --git a/internal/config/concierge/config.go b/internal/config/concierge/config.go index d2faeeda4..9ee82c2f7 100644 --- a/internal/config/concierge/config.go +++ b/internal/config/concierge/config.go @@ -88,6 +88,10 @@ func FromPath(ctx context.Context, path string, setAllowedCiphers ptls.SetAllowe return nil, fmt.Errorf("validate tls: %w", err) } + if err := validateAudit(&config.Audit); err != nil { + return nil, fmt.Errorf("validate audit: %w", err) + } + if config.Labels == nil { config.Labels = make(map[string]string) } @@ -200,3 +204,11 @@ func validateServerPort(port *int64) error { } return nil } + +func validateAudit(auditConfig *AuditSpec) error { + v := auditConfig.LogUsernamesAndGroups + if v != "" && v != Enabled && v != Disabled { + return constable.Error("invalid logUsernamesAndGroups format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')") + } + return nil +} diff --git a/internal/config/concierge/config_test.go b/internal/config/concierge/config_test.go index 37eacd150..82a8c9f4e 100644 --- a/internal/config/concierge/config_test.go +++ b/internal/config/concierge/config_test.go @@ -67,6 +67,8 @@ func TestFromPath(t *testing.T) { - foo - bar - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + audit: + logUsernamesAndGroups: enabled `), wantConfig: &Config{ DiscoveryInfo: DiscoveryInfoSpec{ @@ -115,6 +117,9 @@ func TestFromPath(t *testing.T) { }, }, }, + Audit: AuditSpec{ + LogUsernamesAndGroups: "enabled", + }, }, }, { @@ -155,6 +160,8 @@ func TestFromPath(t *testing.T) { log: level: all format: json + audit: + logUsernamesAndGroups: disabled `), wantConfig: &Config{ DiscoveryInfo: DiscoveryInfoSpec{ @@ -195,6 +202,9 @@ func TestFromPath(t *testing.T) { Level: plog.LevelAll, Format: plog.FormatJSON, }, + Audit: AuditSpec{ + LogUsernamesAndGroups: "disabled", + }, }, }, { @@ -287,6 +297,7 @@ func TestFromPath(t *testing.T) { NamePrefix: ptr.To("pinniped-kube-cert-agent-"), Image: ptr.To("debian:latest"), }, + Audit: AuditSpec{LogUsernamesAndGroups: ""}, }, }, { @@ -629,6 +640,28 @@ func TestFromPath(t *testing.T) { allowedCiphersError: fmt.Errorf("some error from setAllowedCiphers"), wantError: "validate tls: some error from setAllowedCiphers", }, + { + name: "invalid audit.logUsernamesAndGroups format", + yaml: here.Doc(` + --- + names: + servingCertificateSecret: pinniped-concierge-api-tls-serving-certificate + credentialIssuer: pinniped-config + apiService: pinniped-api + impersonationLoadBalancerService: impersonationLoadBalancerService-value + impersonationClusterIPService: impersonationClusterIPService-value + impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value + impersonationCACertificateSecret: impersonationCACertificateSecret-value + impersonationSignerSecret: impersonationSignerSecret-value + impersonationSignerSecret: impersonationSignerSecret-value + agentServiceAccount: agentServiceAccount-value + impersonationProxyServiceAccount: impersonationProxyServiceAccount-value + impersonationProxyLegacySecret: impersonationProxyLegacySecret-value + audit: + logUsernamesAndGroups: this-value-is-not-allowed + `), + wantError: "validate audit: invalid logUsernamesAndGroups format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/config/concierge/types.go b/internal/config/concierge/types.go index b4b814845..550abf991 100644 --- a/internal/config/concierge/types.go +++ b/internal/config/concierge/types.go @@ -5,6 +5,11 @@ package concierge import "go.pinniped.dev/internal/plog" +const ( + Enabled = "enabled" + Disabled = "disabled" +) + // Config contains knobs to set up an instance of the Pinniped Concierge. type Config struct { DiscoveryInfo DiscoveryInfoSpec `json:"discovery"` @@ -17,6 +22,17 @@ type Config struct { Labels map[string]string `json:"labels"` Log plog.LogSpec `json:"log"` TLS TLSSpec `json:"tls"` + Audit AuditSpec `json:"audit"` +} + +type AuditUsernamesAndGroups string + +func (l AuditUsernamesAndGroups) Enabled() bool { + return l == Enabled +} + +type AuditSpec struct { + LogUsernamesAndGroups AuditUsernamesAndGroups `json:"logUsernamesAndGroups"` } type TLSSpec struct { diff --git a/internal/config/supervisor/config.go b/internal/config/supervisor/config.go index 7c5119cbb..d8fc4db78 100644 --- a/internal/config/supervisor/config.go +++ b/internal/config/supervisor/config.go @@ -100,6 +100,10 @@ func FromPath(ctx context.Context, path string, setAllowedCiphers ptls.SetAllowe return nil, fmt.Errorf("validate tls: %w", err) } + if err := validateAudit(&config.Audit); err != nil { + return nil, fmt.Errorf("validate audit: %w", err) + } + return &config, nil } @@ -214,3 +218,23 @@ func validateServerPort(port *int64) error { } return nil } + +func validateAudit(auditConfig *AuditSpec) error { + const errFmt = "invalid %s format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')" + + switch auditConfig.LogUsernamesAndGroups { + case Enabled, Disabled, "": + // no-op + default: + return fmt.Errorf(errFmt, "logUsernamesAndGroups") + } + + switch auditConfig.LogInternalPaths { + case Enabled, Disabled, "": + // no-op + default: + return fmt.Errorf(errFmt, "logInternalPaths") + } + + return nil +} diff --git a/internal/config/supervisor/config_test.go b/internal/config/supervisor/config_test.go index d88344f3b..3492e98c0 100644 --- a/internal/config/supervisor/config_test.go +++ b/internal/config/supervisor/config_test.go @@ -52,6 +52,9 @@ func TestFromPath(t *testing.T) { - foo - bar - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + audit: + logUsernamesAndGroups: enabled + logInternalPaths: enabled `), wantConfig: &Config{ APIGroupSuffix: ptr.To("some.suffix.com"), @@ -86,6 +89,10 @@ func TestFromPath(t *testing.T) { }, }, }, + Audit: AuditSpec{ + LogUsernamesAndGroups: "enabled", + LogInternalPaths: "enabled", + }, }, }, { @@ -123,6 +130,42 @@ func TestFromPath(t *testing.T) { }, }, AggregatedAPIServerPort: ptr.To[int64](10250), + Audit: AuditSpec{ + LogInternalPaths: "", + LogUsernamesAndGroups: "", + }, + }, + }, + { + name: "audit settings can be disabled explicitly", + yaml: here.Doc(` + --- + names: + defaultTLSCertificateSecret: my-secret-name + audit: + logInternalPaths: disabled + logUsernamesAndGroups: disabled + `), + wantConfig: &Config{ + APIGroupSuffix: ptr.To("pinniped.dev"), + Labels: map[string]string{}, + NamesConfig: NamesConfigSpec{ + DefaultTLSCertificateSecret: "my-secret-name", + }, + Endpoints: &Endpoints{ + HTTPS: &Endpoint{ + Network: "tcp", + Address: ":8443", + }, + HTTP: &Endpoint{ + Network: "disabled", + }, + }, + AggregatedAPIServerPort: ptr.To[int64](10250), + Audit: AuditSpec{ + LogInternalPaths: "disabled", + LogUsernamesAndGroups: "disabled", + }, }, }, { @@ -267,6 +310,28 @@ func TestFromPath(t *testing.T) { `), wantError: "validate aggregatedAPIServerPort: must be within range 1024 to 65535", }, + { + name: "invalid audit.logUsernamesAndGroups format", + yaml: here.Doc(` + --- + names: + defaultTLSCertificateSecret: my-secret-name + audit: + logUsernamesAndGroups: this-is-not-a-valid-value + `), + wantError: "validate audit: invalid logUsernamesAndGroups format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')", + }, + { + name: "invalid audit.logInternalPaths format", + yaml: here.Doc(` + --- + names: + defaultTLSCertificateSecret: my-secret-name + audit: + logInternalPaths: this-is-not-a-valid-value + `), + wantError: "validate audit: invalid logInternalPaths format, valid choices are 'enabled', 'disabled', or empty string (equivalent to 'disabled')", + }, { name: "returns setAllowedCiphers errors", yaml: here.Doc(` diff --git a/internal/config/supervisor/types.go b/internal/config/supervisor/types.go index 4f94b81d6..f4771078c 100644 --- a/internal/config/supervisor/types.go +++ b/internal/config/supervisor/types.go @@ -7,6 +7,11 @@ import ( "go.pinniped.dev/internal/plog" ) +const ( + Enabled = "enabled" + Disabled = "disabled" +) + // Config contains knobs to set up an instance of the Pinniped Supervisor. type Config struct { APIGroupSuffix *string `json:"apiGroupSuffix,omitempty"` @@ -20,11 +25,18 @@ type Config struct { } type AuditInternalPaths string +type AuditUsernamesAndGroups string -const AuditInternalPathsEnabled = "Enabled" +func (l AuditInternalPaths) Enabled() bool { + return l == Enabled +} +func (l AuditUsernamesAndGroups) Enabled() bool { + return l == Enabled +} type AuditSpec struct { - InternalPaths AuditInternalPaths `json:"internalPaths"` + LogInternalPaths AuditInternalPaths `json:"logInternalPaths"` + LogUsernamesAndGroups AuditUsernamesAndGroups `json:"logUsernamesAndGroups"` } type TLSSpec struct { diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index b879233c2..741b71dcf 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -57,7 +57,7 @@ func TestGarbageCollectorControllerInformerFilters(t *testing.T) { nil, secretsInformer, observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters - plog.New(), + nil, ) secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer) }) @@ -139,7 +139,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { syncContext *controllerlib.Context fakeClock *clocktesting.FakeClock frozenNow time.Time - auditLog *bytes.Buffer + actualAuditLog *bytes.Buffer wantAuditLogs []testutil.WantedAuditLog ) @@ -148,7 +148,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { var startInformersAndController = func(idpCache dynamicupstreamprovider.DynamicUpstreamIDPProvider) { // Set this at the last second to allow for injection of server override. var auditLogger plog.AuditLogger - auditLogger, auditLog = plog.TestLogger(t) + auditLogger, actualAuditLog = plog.TestAuditLogger(t) subject = GarbageCollectorController( idpCache, fakeClock, @@ -198,7 +198,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { it.After(func() { cancelContextCancelFunc() - testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) }) when("there are secrets without the garbage-collect-after annotation", func() { diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 313c63123..160c1291f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -4027,7 +4027,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset, secretsClient v1.SecretInterface, - auditLog *bytes.Buffer, + actualAuditLog *bytes.Buffer, ) { if test.kubeResources != nil { test.kubeResources(t, supervisorClient, kubeClient) @@ -4118,7 +4118,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(stateparam.Encoded(actualQueryStateParam), sessionID) testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, auditLog.String()) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } switch { @@ -4177,7 +4177,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if len(test.wantDownstreamAdditionalClaims) > 0 { require.True(t, oidcIDPsCount > 0, "wantDownstreamAdditionalClaims requires at least one OIDC IDP") } - auditLogger, auditLog := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := NewHandler( downstreamIssuer, idps, @@ -4186,7 +4186,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo test.stateEncoder, test.cookieEncoder, auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, actualAuditLog) }) } @@ -4202,7 +4202,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient) oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient) idpLister := test.idps.BuildFederationDomainIdentityProvidersListerFinder() - auditLogger, auditLog := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := NewHandler( downstreamIssuer, idpLister, @@ -4212,8 +4212,8 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo auditLogger, ) - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) - auditLog.Reset() // clear the log for the next authorize call + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, actualAuditLog) + actualAuditLog.Reset() // clear the log for the next authorize call // Call the idpLister's setter to change the upstream IDP settings. newProviderSettings := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). @@ -4288,7 +4288,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo // modified expectations. This should ensure that the implementation is using the in-memory cache // of upstream IDP settings appropriately in terms of always getting the values from the cache // on every request. - runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, auditLog) + runOneTestCase(t, test, subject, kubeOauthStore, supervisorClient, kubeClient, secretsClient, actualAuditLog) }) } diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 583fdc02f..e2aafc691 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -1853,7 +1853,7 @@ func TestCallbackEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil) - logger, log := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := NewHandler( test.idps.BuildFederationDomainIdentityProvidersListerFinder(), @@ -1861,7 +1861,7 @@ func TestCallbackEndpoint(t *testing.T) { happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI, - logger, + auditLogger, ) reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") @@ -1959,7 +1959,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path), sessionID) testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } }) } diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index fa753b05f..3182e096f 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -449,9 +449,9 @@ func TestLoginEndpoint(t *testing.T) { return test.postHandlerErr } - logger, log := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) - subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, logger) + subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler, auditLogger) subject.ServeHTTP(rsp, req) @@ -468,7 +468,7 @@ func TestLoginEndpoint(t *testing.T) { if test.wantAuditLogs != nil { wantAuditLogs := test.wantAuditLogs(testutil.GetStateParam(t, test.path)) testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, log.String()) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } }) } diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 3a0545533..62ab8ebf9 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -1147,7 +1147,9 @@ func TestPostLoginEndpoint(t *testing.T) { rsp := httptest.NewRecorder() - subject := NewPostHandler(downstreamIssuer, tt.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, plog.New()) + auditLogger, _ := plog.TestAuditLogger(t) + + subject := NewPostHandler(downstreamIssuer, tt.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, auditLogger) err := subject(rsp, req, happyEncodedUpstreamState, tt.decodedState) if tt.wantErr != "" { diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 4059b68bf..db196ddb2 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -5107,18 +5107,18 @@ func exchangeAuthcodeForTokens( test.makeJwksSigningKeyAndProvider = generateJWTSigningKeyAndJWKSProvider } - logger, actualAuditLog := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) var oauthHelper fosite.OAuth2Provider // Note that makeHappyOauthHelper() calls simulateAuthEndpointHavingAlreadyRun() to preload the session storage. - oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession, logger) + oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession, auditLogger) subject = NewHandler( idps, oauthHelper, timeoutsConfiguration.OverrideDefaultAccessTokenLifespan, timeoutsConfiguration.OverrideDefaultIDTokenLifespan, - logger, + auditLogger, ) authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid") diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index 5d0d943cd..03141895a 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -62,7 +62,7 @@ func NewManager( secretsClient corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface, auditLogger plog.AuditLogger, - auditCfg supervisor.AuditSpec, + auditInternalPathsCfg supervisor.AuditInternalPaths, ) *Manager { m := &Manager{ providerHandlers: make(map[string]http.Handler), @@ -74,7 +74,7 @@ func NewManager( auditLogger: auditLogger, } // nextHandler is the next handler in the chain, called when this manager didn't know how to handle a request - m.buildHandlerChain(nextHandler, auditCfg) + m.buildHandlerChain(nextHandler, auditInternalPathsCfg) return m } @@ -193,11 +193,11 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro } } -func (m *Manager) buildHandlerChain(nextHandler http.Handler, auditCfg supervisor.AuditSpec) { +func (m *Manager) buildHandlerChain(nextHandler http.Handler, auditInternalPathsCfg supervisor.AuditInternalPaths) { // Build the basic handler for FederationDomain endpoints. handler := m.buildManagerHandler(nextHandler) // Log all requests, including audit ID. - handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger, auditCfg) + handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger, auditInternalPathsCfg) // Add random audit ID to request context and response headers. handler = requestlogger.WithAuditID(handler) m.handlerChain = handler diff --git a/internal/federationdomain/endpointsmanager/manager_test.go b/internal/federationdomain/endpointsmanager/manager_test.go index b92af11da..cfb1a1d48 100644 --- a/internal/federationdomain/endpointsmanager/manager_test.go +++ b/internal/federationdomain/endpointsmanager/manager_test.go @@ -360,6 +360,8 @@ func TestManager(t *testing.T) { cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2")) cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02")) + auditLogger, _ := plog.TestAuditLogger(t) + subject = NewManager( nextHandler, dynamicJWKSProvider, @@ -367,8 +369,8 @@ func TestManager(t *testing.T) { &cache, secretsClient, oidcClientsClient, - plog.New(), - supervisor.AuditSpec{}, + auditLogger, + supervisor.Enabled, ) }) diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 899ebceda..44c8c089c 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -49,9 +49,9 @@ func WithAuditID(handler http.Handler) http.Handler { }) } -func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger, auditCfg supervisor.AuditSpec) http.Handler { +func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger, auditInternalPathsCfg supervisor.AuditInternalPaths) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - rl := newRequestLogger(req, w, auditLogger, time.Now(), auditCfg) + rl := newRequestLogger(req, w, auditLogger, time.Now(), auditInternalPathsCfg) rl.logRequestReceived() defer rl.logRequestComplete() @@ -73,19 +73,25 @@ type requestLogger struct { userAgent string w http.ResponseWriter - auditLogger plog.AuditLogger - auditCfg supervisor.AuditSpec + auditLogger plog.AuditLogger + auditInternalPaths bool } -func newRequestLogger(req *http.Request, w http.ResponseWriter, auditLogger plog.AuditLogger, startTime time.Time, auditCfg supervisor.AuditSpec) *requestLogger { +func newRequestLogger( + req *http.Request, + w http.ResponseWriter, + auditLogger plog.AuditLogger, + startTime time.Time, + auditInternalPathsCfg supervisor.AuditInternalPaths, +) *requestLogger { return &requestLogger{ - req: req, - w: w, - startTime: startTime, - clock: clock.RealClock{}, - userAgent: req.UserAgent(), // cache this from the req to avoid any possibility of concurrent read/write problems with headers map - auditLogger: auditLogger, - auditCfg: auditCfg, + req: req, + w: w, + startTime: startTime, + clock: clock.RealClock{}, + userAgent: req.UserAgent(), // cache this from the req to avoid any possibility of concurrent read/write problems with headers map + auditLogger: auditLogger, + auditInternalPaths: auditInternalPathsCfg.Enabled(), } } @@ -98,7 +104,7 @@ func internalPaths() []string { func (rl *requestLogger) logRequestReceived() { r := rl.req - if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { + if !rl.auditInternalPaths && slices.Contains(internalPaths(), r.URL.Path) { return } @@ -148,7 +154,7 @@ func getLocationForAuditLogs(location string) string { func (rl *requestLogger) logRequestComplete() { r := rl.req - if rl.auditCfg.InternalPaths != supervisor.AuditInternalPathsEnabled && slices.Contains(internalPaths(), r.URL.Path) { + if !rl.auditInternalPaths && slices.Contains(internalPaths(), r.URL.Path) { return } diff --git a/internal/federationdomain/requestlogger/request_logger_test.go b/internal/federationdomain/requestlogger/request_logger_test.go index 1ad3e2653..ccf986aed 100644 --- a/internal/federationdomain/requestlogger/request_logger_test.go +++ b/internal/federationdomain/requestlogger/request_logger_test.go @@ -13,7 +13,6 @@ import ( "go.uber.org/mock/gomock" clocktesting "k8s.io/utils/clock/testing" - "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/mocks/mockresponsewriter" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" @@ -39,52 +38,44 @@ func TestLogRequestReceived(t *testing.T) { } tests := []struct { - name string - path string - auditCfg supervisor.AuditSpec - wantAuditLogs []testutil.WantedAuditLog + name string + path string + auditInternalPaths bool + wantAuditLogs []testutil.WantedAuditLog }{ { - name: "when internal paths are not Enabled, ignores internal paths", - path: "/healthz", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: noAuditEventsWanted, + name: "when internal paths are not enabled, ignores internal paths", + path: "/healthz", + auditInternalPaths: false, + wantAuditLogs: noAuditEventsWanted, }, { - name: "when internal paths are not Enabled, audits external path", - path: "/pretend-to-login", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), + name: "when internal paths are not enabled, audits external path", + path: "/pretend-to-login", + auditInternalPaths: false, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), }, { - name: "when internal paths are Enabled, audits internal paths", - path: "/healthz", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Enabled", - }, - wantAuditLogs: happyAuditEventWanted("/healthz"), + name: "when internal paths are enabled, audits internal paths", + path: "/healthz", + auditInternalPaths: true, + wantAuditLogs: happyAuditEventWanted("/healthz"), }, { - name: "when internal paths are Enabled, audits external paths", - path: "/pretend-to-login", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Enabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), + name: "when internal paths are enabled, audits external paths", + path: "/pretend-to-login", + auditInternalPaths: true, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login"), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - logger, log := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := requestLogger{ - auditLogger: logger, + auditLogger: auditLogger, req: &http.Request{ Method: "some-method", Proto: "some-proto", @@ -97,13 +88,13 @@ func TestLogRequestReceived(t *testing.T) { ServerName: "some-sni-server-name", }, }, - userAgent: "some-user-agent", - auditCfg: test.auditCfg, + userAgent: "some-user-agent", + auditInternalPaths: test.auditInternalPaths, } subject.logRequestReceived() - testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) + testutil.CompareAuditLogs(t, test.wantAuditLogs, actualAuditLog.String()) }) } } @@ -127,45 +118,37 @@ func TestLogRequestComplete(t *testing.T) { } tests := []struct { - name string - path string - location string - auditCfg supervisor.AuditSpec - wantAuditLogs []testutil.WantedAuditLog + name string + path string + location string + auditInternalPaths bool + wantAuditLogs []testutil.WantedAuditLog }{ { - name: "when internal paths are not Enabled, ignores internal paths", - path: "/healthz", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: noAuditEventsWanted, + name: "when internal paths are not enabled, ignores internal paths", + path: "/healthz", + auditInternalPaths: false, + wantAuditLogs: noAuditEventsWanted, }, { - name: "when internal paths are not Enabled, audits external path with location (redacting unknown query params)", - path: "/pretend-to-login", - location: "http://127.0.0.1?foo=bar&foo=quz&lorem=ipsum", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Disabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "http://127.0.0.1?foo=redacted&foo=redacted&lorem=redacted"), + name: "when internal paths are not enabled, audits external path with location (redacting unknown query params)", + path: "/pretend-to-login", + location: "http://127.0.0.1?foo=bar&foo=quz&lorem=ipsum", + auditInternalPaths: false, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "http://127.0.0.1?foo=redacted&foo=redacted&lorem=redacted"), }, { - name: "when internal paths are Enabled, audits internal paths", - path: "/healthz", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Enabled", - }, - wantAuditLogs: happyAuditEventWanted("/healthz", "no location header"), + name: "when internal paths are enabled, audits internal paths", + path: "/healthz", + auditInternalPaths: true, + wantAuditLogs: happyAuditEventWanted("/healthz", "no location header"), }, { - name: "when internal paths are Enabled, audits external paths", - path: "/pretend-to-login", - location: "some-location", - auditCfg: supervisor.AuditSpec{ - InternalPaths: "Enabled", - }, - wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), + name: "when internal paths are enabled, audits external paths", + path: "/pretend-to-login", + location: "some-location", + auditInternalPaths: true, + wantAuditLogs: happyAuditEventWanted("/pretend-to-login", "some-location"), }, { name: "audits path without location", @@ -203,10 +186,10 @@ func TestLogRequestComplete(t *testing.T) { }) } - logger, log := plog.TestLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := requestLogger{ - auditLogger: logger, + auditLogger: auditLogger, startTime: startTime, clock: frozenClock, req: &http.Request{ @@ -214,14 +197,14 @@ func TestLogRequestComplete(t *testing.T) { Path: test.path, }, }, - status: 777, - w: mockResponseWriter, - auditCfg: test.auditCfg, + status: 777, + w: mockResponseWriter, + auditInternalPaths: test.auditInternalPaths, } subject.logRequestComplete() - testutil.CompareAuditLogs(t, test.wantAuditLogs, log.String()) + testutil.CompareAuditLogs(t, test.wantAuditLogs, actualAuditLog.String()) }) } } diff --git a/internal/plog/plog.go b/internal/plog/plog.go index d78022d10..1915b0302 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -71,8 +71,6 @@ type AuditLogger interface { // If test assertions are desired, Logger should be passed in as an input. New should be used as the // production implementation and TestLogger should be used to write test assertions. type Logger interface { - AuditLogger - Error(msg string, err error, keysAndValues ...any) Warning(msg string, keysAndValues ...any) WarningErr(msg string, err error, keysAndValues ...any) @@ -92,6 +90,7 @@ type Logger interface { // for internal and test use only withDepth(d int) Logger withLogrMod(mod func(logr.Logger) logr.Logger) Logger + audit(msg string, keysAndValues ...any) } // MinLogger is the overlap between Logger and logr.Logger. @@ -107,28 +106,34 @@ type pLogger struct { depth int } +type AuditLogConfig struct { + LogUsernamesAndGroupNames bool +} + +type auditLogger struct { + cfg AuditLogConfig + logger Logger +} + func New() Logger { return pLogger{} } -// Error logs show in the pod log output as `"level":"error","message":"some error msg"` -// where the message text comes from the err parameter. -// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. -// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. -// Error logs cannot be suppressed by the global log level configuration. -func (p pLogger) Error(msg string, err error, keysAndValues ...any) { - p.logr().WithCallDepth(p.depth+1).Error(err, msg, keysAndValues...) +func NewAuditLogger(cfg AuditLogConfig) AuditLogger { + return &auditLogger{ + cfg: cfg, + logger: New(), + } } // Audit logs show in the pod log output as `"level":"info","message":"some msg","auditEvent":true` // where the message text comes from the msg parameter. // They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. // Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. -// Audit logs cannot be suppressed by the global log level configuration, but rather can be disabled -// by their own separate configuration. This is because Audit logs should always be printed when they are desired -// by the admin, regardless of global log level, yet the admin should also have a way to entirely disable them -// when they want to avoid potential PII (e.g. usernames) in their pod logs. -func (p pLogger) Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { +// Audit logs cannot be suppressed by the global log level configuration. This is because Audit logs should always +// be printed, regardless of global log level. Audit logs offer their own configuration options, such as a way to +// avoid potential PII (e.g. usernames and group names) in their pod logs. +func (a *auditLogger) Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { // Always add a key/value auditEvent=true. keysAndValues = slices.Concat([]any{"auditEvent", true}, keysAndValues) @@ -148,7 +153,23 @@ func (p pLogger) Audit(msg auditevent.Message, reqCtx context.Context, session S keysAndValues = slices.Concat([]any{"sessionID", sessionID}, keysAndValues) } - p.logr().V(klogLevelWarning).WithCallDepth(p.depth+1).Info(string(msg), keysAndValues...) + a.logger.audit(string(msg), keysAndValues...) +} + +// audit is used internally by AuditLogger to print an audit log event to the pLogger's output. +func (p pLogger) audit(msg string, keysAndValues ...any) { + // Always print log message (klogLevelWarning cannot be suppressed by configuration), + // and always use the Info function because audit logs are not warnings or errors. + p.logr().V(klogLevelWarning).WithCallDepth(p.depth+1).Info(msg, keysAndValues...) +} + +// Error logs show in the pod log output as `"level":"error","message":"some error msg"` +// where the message text comes from the err parameter. +// They also contain the standard `timestamp` and `caller` keys, along with any other keysAndValues. +// Only when the global log level is configured to "trace" or "all", then they will also include a `stacktrace` key. +// Error logs cannot be suppressed by the global log level configuration. +func (p pLogger) Error(msg string, err error, keysAndValues ...any) { + p.logr().WithCallDepth(p.depth+1).Error(err, msg, keysAndValues...) } func (p pLogger) warningDepth(msg string, depth int, keysAndValues ...any) { diff --git a/internal/plog/testing.go b/internal/plog/testing.go index c7b639410..121dbc0f7 100644 --- a/internal/plog/testing.go +++ b/internal/plog/testing.go @@ -71,6 +71,13 @@ func TestLogger(t *testing.T) (Logger, *bytes.Buffer) { &log } +func TestAuditLogger(t *testing.T) (AuditLogger, *bytes.Buffer) { + t.Helper() + + underlyingLogger, logBuf := TestLogger(t) + return &auditLogger{logger: underlyingLogger, cfg: AuditLogConfig{LogUsernamesAndGroupNames: true}}, logBuf +} + func TestConsoleLogger(t *testing.T, w io.Writer) Logger { t.Helper() diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 20f8b6c41..c1c8e5e10 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -33,7 +33,7 @@ import ( ) func TestNew(t *testing.T) { - r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, plog.New()) + r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, nil) require.NotNil(t, r) require.False(t, r.NamespaceScoped()) require.Equal(t, []string{"pinniped"}, r.Categories()) @@ -70,6 +70,7 @@ func TestCreate(t *testing.T) { var ctrl *gomock.Controller var logger *testutil.TranscriptLogger var originalKLogLevel klog.Level + var auditLogger plog.AuditLogger it.Before(func() { r = require.New(t) @@ -79,6 +80,7 @@ func TestCreate(t *testing.T) { originalKLogLevel = testutil.GetGlobalKlogLevel() // trace.Log() utility will only log at level 2 or above, so set that for this test. testutil.SetGlobalKlogLevel(t, 2) //nolint:staticcheck // old test of code using trace.Log() + auditLogger, _ = plog.TestAuditLogger(t) }) it.After(func() { @@ -104,7 +106,7 @@ func TestCreate(t *testing.T) { 5*time.Minute, ).Return([]byte("test-cert"), []byte("test-key"), nil) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -143,7 +145,7 @@ func TestCreate(t *testing.T) { IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil, fmt.Errorf("some certificate authority error")) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) @@ -156,7 +158,7 @@ func TestCreate(t *testing.T) { requestAuthenticator := mockcredentialrequest.NewMockTokenCredentialRequestAuthenticator(ctrl) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -171,7 +173,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(nil, errors.New("some webhook error")) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -186,7 +188,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(&user.DefaultInfo{Name: ""}, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -205,7 +207,7 @@ func TestCreate(t *testing.T) { Groups: []string{"test-group-1", "test-group-2"}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -224,7 +226,7 @@ func TestCreate(t *testing.T) { Extra: map[string][]string{"test-key": {"test-val-1", "test-val-2"}}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, req) @@ -234,7 +236,7 @@ func TestCreate(t *testing.T) { it("CreateFailsWhenGivenTheWrongInputType", func() { notACredentialRequest := runtime.Unknown{} - response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.NewContext(), ¬ACredentialRequest, rest.ValidateAllObjectFunc, @@ -245,7 +247,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ Token: "", })) @@ -256,7 +258,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenValidationFails", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, plog.New()) + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) response, err := storage.Create( context.Background(), validCredentialRequest(), @@ -276,7 +278,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger) response, err := storage.Create( context.Background(), req, @@ -297,7 +299,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, plog.New()) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger) validationFunctionWasCalled := false var validationFunctionSawTokenValue string response, err := storage.Create( @@ -317,7 +319,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.NewContext(), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -331,7 +333,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenNamespaceIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, plog.New()).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), validCredentialRequest(), rest.ValidateAllObjectFunc, diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index 871b9abf3..8307df4db 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -149,6 +149,7 @@ func prepareControllers( pinnipedInformers supervisorinformers.SharedInformerFactory, leaderElector controllerinit.RunnerWrapper, podInfo *downward.PodInfo, + auditLogger plog.AuditLogger, ) controllerinit.RunnerBuilder { const certificateName string = "pinniped-supervisor-api-tls-serving-certificate" clientSecretSupervisorGroupData := groupsuffix.SupervisorAggregatedGroups(*cfg.APIGroupSuffix) @@ -167,7 +168,7 @@ func prepareControllers( kubeClient, secretInformer, controllerlib.WithInformer, - plog.New(), + auditLogger, ), singletonWorker, ). @@ -451,6 +452,10 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis return fmt.Errorf("cannot create k8s client without leader election: %w", err) } + auditLogger := plog.NewAuditLogger(plog.AuditLogConfig{ + LogUsernamesAndGroupNames: cfg.Audit.LogUsernamesAndGroups.Enabled(), + }) + kubeInformers := k8sinformers.NewSharedInformerFactoryWithOptions( client.Kubernetes, defaultResyncInterval, @@ -484,8 +489,8 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis &secretCache, clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders client.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), - plog.New(), - cfg.Audit, + auditLogger, + cfg.Audit.LogInternalPaths, ) // Get the "real" name of the client secret supervisor API group (i.e., the API group name with the @@ -508,6 +513,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis pinnipedInformers, leaderElector, podInfo, + auditLogger, ) shutdown := &sync.WaitGroup{} From c5f4cce3aec35a23690ace6b6a1c811b18463689 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 11 Nov 2024 18:05:00 -0800 Subject: [PATCH 29/71] make Audit() take struct as param for all optional params and redact PII --- .../supervisorstorage/garbage_collector.go | 18 +- .../downstreamsession/downstream_session.go | 41 +- .../endpoints/auth/auth_handler.go | 38 +- .../endpoints/callback/callback_handler.go | 8 +- .../endpoints/login/login_handler.go | 6 +- .../endpoints/token/token_handler.go | 30 +- .../tokenendpointauditor/parameter_auditor.go | 7 +- .../requestlogger/request_logger.go | 40 +- internal/plog/plog.go | 103 +++- internal/plog/plog_test.go | 536 +++++++++++------- internal/plog/testing.go | 6 +- internal/registry/credentialrequest/rest.go | 14 +- 12 files changed, 562 insertions(+), 285 deletions(-) diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index 7f64cf527..394ddf371 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -291,8 +291,10 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, - "type", upstreamprovider.RefreshTokenType) + c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, &plog.AuditParams{ + Session: request, + KeysAndValues: []any{"type", upstreamprovider.RefreshTokenType}, + }) plog.Trace("garbage collector successfully revoked upstream OIDC refresh token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -301,8 +303,10 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( if err != nil { return err } - c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, plog.NoHTTPRequestAvailable(), request, - "type", upstreamprovider.AccessTokenType) + c.auditLogger.Audit(auditevent.UpstreamOIDCTokenRevoked, &plog.AuditParams{ + Session: request, + KeysAndValues: []any{"type", upstreamprovider.AccessTokenType}, + }) plog.Trace("garbage collector successfully revoked upstream OIDC access token (or provider has no revocation endpoint)", logKV(secret)...) } @@ -312,8 +316,10 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken( func (c *garbageCollectorController) maybeAuditLogGC(storageType string, secret *corev1.Secret) { r, err := c.requestFromSecret(storageType, secret) if err == nil && r != nil { - c.auditLogger.Audit(auditevent.SessionGarbageCollected, plog.NoHTTPRequestAvailable(), r, - "storageType", storageType) + c.auditLogger.Audit(auditevent.SessionGarbageCollected, &plog.AuditParams{ + Session: r, + KeysAndValues: []any{"storageType", storageType}, + }) } } diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 8a4bcb4d8..609d8ece8 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -50,19 +50,25 @@ func NewPinnipedSession( ) (*psession.PinnipedSession, error) { now := time.Now().UTC() - auditLogger.Audit(auditevent.IdentityFromUpstreamIDP, ctx, plog.NoSessionPersisted(), - "upstreamIDPDisplayName", c.IdentityProvider.GetDisplayName(), - "upstreamIDPType", c.IdentityProvider.GetSessionProviderType(), - "upstreamIDPResourceName", c.IdentityProvider.GetProvider().GetResourceName(), - "upstreamIDPResourceUID", c.IdentityProvider.GetProvider().GetResourceUID(), - "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, - "upstreamGroups", c.UpstreamIdentity.UpstreamGroups) + auditLogger.Audit(auditevent.IdentityFromUpstreamIDP, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "upstreamIDPDisplayName", c.IdentityProvider.GetDisplayName(), + "upstreamIDPType", c.IdentityProvider.GetSessionProviderType(), + "upstreamIDPResourceName", c.IdentityProvider.GetProvider().GetResourceName(), + "upstreamIDPResourceUID", c.IdentityProvider.GetProvider().GetResourceUID(), + "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, + "upstreamGroups", c.UpstreamIdentity.UpstreamGroups, + }, + }) downstreamUsername, downstreamGroups, err := applyIdentityTransformations(ctx, c.IdentityProvider.GetTransforms(), c.UpstreamIdentity.UpstreamUsername, c.UpstreamIdentity.UpstreamGroups) if err != nil { - auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, ctx, plog.NoSessionPersisted(), - "reason", err) + auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{"reason", err}, + }) return nil, err } @@ -109,12 +115,17 @@ func NewPinnipedSession( pinnipedSession.IDTokenClaims().Extra = extras - auditLogger.Audit(auditevent.SessionStarted, ctx, c.SessionIDGetter, - "username", downstreamUsername, - "groups", downstreamGroups, - "subject", c.UpstreamIdentity.DownstreamSubject, - "additionalClaims", c.UpstreamLoginExtras.DownstreamAdditionalClaims, - "warnings", c.UpstreamLoginExtras.Warnings) + auditLogger.Audit(auditevent.SessionStarted, &plog.AuditParams{ + ReqCtx: ctx, + Session: c.SessionIDGetter, + KeysAndValues: []any{ + "username", downstreamUsername, + "groups", downstreamGroups, + "subject", c.UpstreamIdentity.DownstreamSubject, + "additionalClaims", c.UpstreamLoginExtras.DownstreamAdditionalClaims, + "warnings", c.UpstreamLoginExtras.Warnings, + }, + }) return pinnipedSession, nil } diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index a046ccc75..93bffc61f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -112,12 +112,18 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Log if these headers were present, but don't log the actual values. The password is obviously sensitive, // and sometimes users use their password as their username by mistake. - h.auditLogger.Audit(auditevent.HTTPRequestCustomHeadersUsed, r.Context(), plog.NoSessionPersisted(), - oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader, - oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader) + h.auditLogger.Audit(auditevent.HTTPRequestCustomHeadersUsed, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader, + oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader, + }, + }) - h.auditLogger.Audit(auditevent.HTTPRequestParameters, r.Context(), plog.NoSessionPersisted(), - auditevent.SanitizeParams(r.Form, paramsSafeToLog())...) + h.auditLogger.Audit(auditevent.HTTPRequestParameters, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: auditevent.SanitizeParams(r.Form, paramsSafeToLog()), + }) if r.Method != http.MethodPost && r.Method != http.MethodGet { // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest @@ -156,17 +162,21 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.auditLogger.Audit(auditevent.UsingUpstreamIDP, r.Context(), plog.NoSessionPersisted(), - "displayName", idp.GetDisplayName(), - "resourceName", idp.GetProvider().GetResourceName(), - "resourceUID", idp.GetProvider().GetResourceUID(), - "type", idp.GetSessionProviderType()) + h.auditLogger.Audit(auditevent.UsingUpstreamIDP, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + "displayName", idp.GetDisplayName(), + "resourceName", idp.GetProvider().GetResourceName(), + "resourceUID", idp.GetProvider().GetResourceUID(), + "type", idp.GetSessionProviderType(), + }, + }) h.authorize(w, r, requestedBrowserlessFlow, idp) } // parseForm parses the query params and/or POST body form params. It returns an error, or in the case of success it -// has the side-effect of leaving the parsed form params on the http.Request in the Form field. Request body +// has the side effect of leaving the parsed form params on the http.Request in the Form field. Request body // parameters take precedence over URL query string values. func parseForm(r *http.Request) error { // The style of form parsing and the text of the error is inspired by fosite's implementation of NewAuthorizeRequest(). @@ -221,8 +231,10 @@ func (h *authorizeHandler) authorize( authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp) if err == nil { - h.auditLogger.Audit(auditevent.UpstreamAuthorizeRedirect, r.Context(), plog.NoSessionPersisted(), - "authorizeID", authorizeID) + h.auditLogger.Audit(auditevent.UpstreamAuthorizeRedirect, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{"authorizeID", authorizeID}, + }) } } if err != nil { diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 8078e530b..79e149e50 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -34,8 +34,10 @@ func NewHandler( return err } - auditLogger.Audit(auditevent.AuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), - "authorizeID", encodedState.AuthorizeID()) + auditLogger.Audit(auditevent.AuthorizeIDFromParameters, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{"authorizeID", encodedState.AuthorizeID()}, + }) idp, err := upstreamIDPs.FindUpstreamIDPByDisplayName(decodedState.UpstreamName) if err != nil || idp == nil { @@ -49,7 +51,7 @@ func NewHandler( return httperr.New(http.StatusBadRequest, "error reading state downstream auth params") } - // Recreate enough of the original authorize request so we can pass it to NewAuthorizeRequest(). + // Recreate enough of the original authorize request, so we can pass it to NewAuthorizeRequest(). reconstitutedAuthRequest := &http.Request{Form: downstreamAuthParams} authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), reconstitutedAuthRequest) if err != nil { diff --git a/internal/federationdomain/endpoints/login/login_handler.go b/internal/federationdomain/endpoints/login/login_handler.go index d1a4c4126..288694db1 100644 --- a/internal/federationdomain/endpoints/login/login_handler.go +++ b/internal/federationdomain/endpoints/login/login_handler.go @@ -59,8 +59,10 @@ func NewHandler( return err } - auditLogger.Audit(auditevent.AuthorizeIDFromParameters, r.Context(), plog.NoSessionPersisted(), - "authorizeID", encodedState.AuthorizeID()) + auditLogger.Audit(auditevent.AuthorizeIDFromParameters, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{"authorizeID", encodedState.AuthorizeID()}, + }) switch decodedState.UpstreamType { case string(idpdiscoveryv1alpha1.IDPTypeLDAP), string(idpdiscoveryv1alpha1.IDPTypeActiveDirectory): diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index e701be106..46f3b7c05 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -192,9 +192,14 @@ func upstreamRefresh( return err } - auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, ctx, accessRequest, - "upstreamUsername", refreshedIdentity.UpstreamUsername, - "upstreamGroups", refreshedIdentity.UpstreamGroups) + auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, &plog.AuditParams{ + ReqCtx: ctx, + Session: accessRequest, + KeysAndValues: []any{ + "upstreamUsername", refreshedIdentity.UpstreamUsername, + "upstreamGroups", refreshedIdentity.UpstreamGroups, + }, + }) // If the idp wants to update the session with new information from the refresh, then update it. if refreshedIdentity.IDPSpecificSessionData != nil { @@ -221,8 +226,11 @@ func upstreamRefresh( if fositeErr != nil { // The HintField is always populated by applyIdentityTransformationsDuringRefresh, // and more descriptive than fositeErr.Error() which is just "error". - auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, ctx, accessRequest, - "reason", fositeErr.HintField) + auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, &plog.AuditParams{ + ReqCtx: ctx, + Session: accessRequest, + KeysAndValues: []any{"reason", fositeErr.HintField}, + }) return fositeErr } @@ -239,10 +247,14 @@ func upstreamRefresh( session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedTransformedGroups } - auditLogger.Audit(auditevent.SessionRefreshed, ctx, accessRequest, - "username", oldTransformedUsername, // not allowed to change above so must be the same as old - "groups", refreshedTransformedGroups, - "subject", previousIdentity.DownstreamSubject) + auditLogger.Audit(auditevent.SessionRefreshed, &plog.AuditParams{ + ReqCtx: ctx, + Session: accessRequest, + KeysAndValues: []any{ + "username", oldTransformedUsername, // not allowed to change above so must be the same as old + "groups", refreshedTransformedGroups, + "subject", previousIdentity.DownstreamSubject}, + }) return nil } diff --git a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go index 011949f91..e4765a17d 100644 --- a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go +++ b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go @@ -53,8 +53,9 @@ func paramsSafeToLogTokenEndpoint() sets.Set[string] { } func (p parameterAuditorHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { - p.auditLogger.Audit(auditevent.HTTPRequestParameters, ctx, plog.NoSessionPersisted(), - auditevent.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint())...) - + p.auditLogger.Audit(auditevent.HTTPRequestParameters, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: auditevent.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint()), + }) return false } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 44c8c089c..b73a34cd4 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -109,17 +109,18 @@ func (rl *requestLogger) logRequestReceived() { } // Always log all other requests, including 404's caused by bad paths, for debugging purposes. - rl.auditLogger.Audit(auditevent.HTTPRequestReceived, - r.Context(), - plog.NoSessionPersisted(), - "proto", r.Proto, - "method", r.Method, - "host", r.Host, - "serverName", requestutil.SNIServerName(r), - "path", r.URL.Path, - "userAgent", rl.userAgent, - "remoteAddr", r.RemoteAddr, - ) + rl.auditLogger.Audit(auditevent.HTTPRequestReceived, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + "proto", r.Proto, + "method", r.Method, + "host", r.Host, + "serverName", requestutil.SNIServerName(r), + "path", r.URL.Path, + "userAgent", rl.userAgent, + "remoteAddr", r.RemoteAddr, + }, + }) } func getLocationForAuditLogs(location string) string { @@ -158,14 +159,15 @@ func (rl *requestLogger) logRequestComplete() { return } - rl.auditLogger.Audit(auditevent.HTTPRequestCompleted, - r.Context(), - plog.NoSessionPersisted(), - "path", r.URL.Path, // include the path again to make it easy to "grep -v healthz" to watch all other audit events - "latency", rl.clock.Since(rl.startTime), - "responseStatus", rl.status, - "location", getLocationForAuditLogs(rl.Header().Get("Location")), - ) + rl.auditLogger.Audit(auditevent.HTTPRequestCompleted, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + "path", r.URL.Path, + "latency", rl.clock.Since(rl.startTime), + "responseStatus", rl.status, + "location", getLocationForAuditLogs(rl.Header().Get("Location")), + }, + }) } // Unwrap implements responsewriter.UserProvidedDecorator. diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 1915b0302..a80031997 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -30,6 +30,7 @@ package plog import ( "context" "os" + "reflect" "slices" "github.com/go-logr/logr" @@ -56,14 +57,31 @@ func NoHTTPRequestAvailable() context.Context { return nil } -// AuditLogger is only the audit logging part of Logger. There is no global function for Audit because +type AuditParams struct { + // ReqCtx may be nil. When possible, pass the http request's context as ReqCtx, + // so we may read the audit ID from the context. + ReqCtx context.Context + + // Session may be nil. When possible, pass the fosite.Requester or fosite.Request as the Session, + // so we can log the session ID. + Session SessionIDGetter + + // PIIKeysAndValues can optionally be used to pass along more keys are values. + // Use these when the values might contain personally identifiable information (PII). + // These values may be redacted by configuration. + // They must come in alternating pairs of string keys and any values. + PIIKeysAndValues []any + + // KeysAndValues can optionally be used to pass along more keys are values. + // These values are never redacted and therefore should never contain PII. + // They must come in alternating pairs of string keys and any values. + KeysAndValues []any +} + +// AuditLogger is the interface for audit logging. There is no global function for Audit because // that would make unit testing of audit logs harder. type AuditLogger interface { - // Audit writes an audit event to the log. - // reqCtx and session may be null. - // When possible, pass the http request's context as reqCtx, so we may read the audit ID from the context. - // When possible, pass the fosite.Requester or fosite.Request as the session, so we can log the session ID. - Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) + Audit(msg auditevent.Message, p *AuditParams) } // Logger implements the plog logging convention described above. The global functions in this package @@ -133,27 +151,82 @@ func NewAuditLogger(cfg AuditLogConfig) AuditLogger { // Audit logs cannot be suppressed by the global log level configuration. This is because Audit logs should always // be printed, regardless of global log level. Audit logs offer their own configuration options, such as a way to // avoid potential PII (e.g. usernames and group names) in their pod logs. -func (a *auditLogger) Audit(msg auditevent.Message, reqCtx context.Context, session SessionIDGetter, keysAndValues ...any) { +// msg is required. All fields of p, and p itself, are optional. +func (a *auditLogger) Audit(msg auditevent.Message, p *AuditParams) { // Always add a key/value auditEvent=true. - keysAndValues = slices.Concat([]any{"auditEvent", true}, keysAndValues) + allKV := []any{"auditEvent", true} var auditID string - if reqCtx != nil { - auditID = audit.GetAuditIDTruncated(reqCtx) + if p != nil && p.ReqCtx != nil { + auditID = audit.GetAuditIDTruncated(p.ReqCtx) } if len(auditID) > 0 { - keysAndValues = slices.Concat([]any{"auditID", auditID}, keysAndValues) + allKV = slices.Concat(allKV, []any{"auditID", auditID}) } var sessionID string - if session != nil { - sessionID = session.GetID() + if p != nil && p.Session != nil { + sessionID = p.Session.GetID() } if len(sessionID) > 0 { - keysAndValues = slices.Concat([]any{"sessionID", sessionID}, keysAndValues) + allKV = slices.Concat(allKV, []any{"sessionID", sessionID}) } - a.logger.audit(string(msg), keysAndValues...) + if p != nil && len(p.PIIKeysAndValues) > 0 { + allKV = slices.Concat(allKV, []any{ + "personalInfo", a.nestedPIIKeysAndValues(p.PIIKeysAndValues, a.cfg.LogUsernamesAndGroupNames), + }) + } + + if p != nil && p.KeysAndValues != nil { + allKV = slices.Concat(allKV, p.KeysAndValues) + } + + a.logger.audit(string(msg), allKV...) +} + +func (a *auditLogger) nestedPIIKeysAndValues(values []any, logUsernamesAndGroupNames bool) map[string]any { + // TODO: This implementation alphabetizes the keys because it builds a map to nest all key/values deeper. Could we keep the original order instead? Do we care? + kvMap := map[string]any{} + var k string + + for i, v := range values { + if i%2 == 0 { + // Interpret even indices (0, 2, 4, etc.) as a key. + // Just remember its value for the next loop iteration. + k = valueAsKey(v) + } else { + // Interpret odd indices as a value. + // Save it using the key from the previous loop iteration. + kvMap[k] = valueAsValue(v, logUsernamesAndGroupNames) + } + } + + return kvMap +} + +func valueAsKey(v any) string { + vStr, ok := v.(string) + if !ok { + // Indicates programmer error that will hopefully be caught by unit tests. + vStr = "cannotCastKeyNameToString" + } + return vStr +} + +func valueAsValue(v any, logUsernamesAndGroupNames bool) any { + if logUsernamesAndGroupNames { + return v // use the original value without redacting + } else { + rt := reflect.TypeOf(v) + if rt.Kind() == reflect.Slice { + // For any slice, replace it by a redacted slice. + return []string{"redacted"} + } else { + // For anything else, just redact it without keeping any hint of the original type. + return "redacted" + } + } } // audit is used internally by AuditLogger to print an audit log event to the pLogger's output. diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index 894a28c22..7b5b3b4e1 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -4,6 +4,7 @@ package plog import ( + "context" "fmt" "runtime" "strings" @@ -12,8 +13,154 @@ import ( "github.com/coreos/go-semver/semver" "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/audit" + + "go.pinniped.dev/internal/here" ) +type fakeSessionGetter struct{} + +func (f fakeSessionGetter) GetID() string { + return "fake-session-id" +} + +func TestAudit(t *testing.T) { + fakeReqContext := audit.WithAuditContext(context.Background()) + audit.WithAuditID(fakeReqContext, "fake-audit-id") + + tests := []struct { + name string + redactPII bool + run func(AuditLogger) + want string + }{ + { + name: "only message, with both nil and empty audit params", + run: func(a AuditLogger) { + a.Audit("fake event type 1", nil) + a.Audit("fake event type 2", &AuditParams{}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type 1","auditEvent":true} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type 2","auditEvent":true} + `), + }, + { + name: "with request context which has no audit ID", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{ReqCtx: context.Background()}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true} + `), + }, + { + name: "with request context which has audit ID", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{ReqCtx: fakeReqContext}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id"} + `), + }, + { + name: "with session getter", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{Session: &fakeSessionGetter{}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"sessionID":"fake-session-id"} + `), + }, + { + name: "with an even number of PII keys and values", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "foo", 42}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":42,"groups":["g1","g2"],"username":"ryan"}} + `), + }, + { + name: "with an even number of PII keys and values and PII configured to be redacted", + redactPII: true, + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "foo", 42}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":"redacted","groups":["redacted"],"username":"redacted"}} + `), + }, + { + name: "with an illegal odd number of PII keys and values, quietly ignores the last one", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"foo", 42, "bar"}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":42}} + `), + }, + { + name: "with a PII keys that is not a string, converts it to an error-looking key name rather than having the function return errors or panic", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{42, "foo", "bar", "baz"}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"bar":"baz","cannotCastKeyNameToString":"foo"}} + `), + }, + { + name: "with arbitrary keys and values", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{KeysAndValues: []any{"foo", 42, "bar", "baz"}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"foo":42,"bar":"baz"} + `), + }, + { + name: "with everything, showing order of keys printed in log", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{ + ReqCtx: fakeReqContext, + Session: &fakeSessionGetter{}, + PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "bat", 14}, + KeysAndValues: []any{"foo", 42, "bar", "baz"}, + }) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"bat":14,"groups":["g1","g2"],"username":"ryan"},"foo":42,"bar":"baz"} + `), + }, + { + name: "with everything, when PII is redacted, showing order of keys printed in log", + redactPII: true, + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{ + ReqCtx: fakeReqContext, + Session: &fakeSessionGetter{}, + PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "bat", 14}, + KeysAndValues: []any{"foo", 42, "bar", "baz"}, + }) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"bat":"redacted","groups":["redacted"],"username":"redacted"},"foo":42,"bar":"baz"} + `), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + l, actualAuditLogs := TestAuditLoggerWithConfig(t, AuditLogConfig{LogUsernamesAndGroupNames: !test.redactPII}) + test.run(l) + + require.Equal(t, strings.TrimSpace(test.want), strings.TrimSpace(actualAuditLogs.String())) + }) + } +} + func TestPlog(t *testing.T) { runtimeVersion := runtime.Version() if strings.HasPrefix(runtimeVersion, "go") { @@ -30,246 +177,247 @@ func TestPlog(t *testing.T) { { name: "basic", run: testAllPlogMethods, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} + `), }, { name: "with values", run: func(l Logger) { testAllPlogMethods(l.WithValues("hi", 42)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","hi":42,"panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","hi":42,"warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","hi":42,"warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","hi":42,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","hi":42,"error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","hi":42,"panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","hi":42,"error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","hi":42,"panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","hi":42,"error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","hi":42,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","hi":42,"panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","hi":42,"panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","hi":42,"warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","hi":42,"warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","hi":42,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","hi":42,"error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","hi":42,"panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","hi":42,"error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","hi":42,"panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","hi":42,"error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","hi":42,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","hi":42,"panda":2} + `), }, { name: "with values conflict", // duplicate key is included twice ... run: func(l Logger) { testAllPlogMethods(l.WithValues("panda", false)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":false,"panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","panda":false,"warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","panda":false,"warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":false,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","panda":false,"error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":false,"panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","panda":false,"error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":false,"panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","panda":false,"error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":false,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":false,"panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":false,"panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","panda":false,"warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","panda":false,"warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":false,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","panda":false,"error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":false,"panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","panda":false,"error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":false,"panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","panda":false,"error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":false,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":false,"panda":2} + `), }, { name: "with values nested", run: func(l Logger) { testAllPlogMethods(l.WithValues("hi", 42).WithValues("not", time.Hour)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","hi":42,"not":"1h0m0s","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","hi":42,"not":"1h0m0s","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","hi":42,"not":"1h0m0s","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","hi":42,"not":"1h0m0s","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","hi":42,"not":"1h0m0s","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","hi":42,"not":"1h0m0s","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","hi":42,"not":"1h0m0s","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","hi":42,"not":"1h0m0s","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","hi":42,"not":"1h0m0s","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","hi":42,"not":"1h0m0s","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","hi":42,"not":"1h0m0s","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","hi":42,"not":"1h0m0s","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","hi":42,"not":"1h0m0s","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","hi":42,"not":"1h0m0s","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","hi":42,"not":"1h0m0s","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","hi":42,"not":"1h0m0s","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","hi":42,"not":"1h0m0s","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","hi":42,"not":"1h0m0s","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","hi":42,"not":"1h0m0s","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","hi":42,"not":"1h0m0s","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","hi":42,"not":"1h0m0s","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","hi":42,"not":"1h0m0s","panda":2} + `), }, { name: "with name", run: func(l Logger) { testAllPlogMethods(l.WithName("yoyo")) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} + `), }, { name: "with name nested", run: func(l Logger) { testAllPlogMethods(l.WithName("yoyo").WithName("gold")) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"yoyo.gold","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} + `), }, { name: "depth 3", run: func(l Logger) { testAllPlogMethods(l.withDepth(3)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"testing/testing.go:$testing.tRunner","message":"always","panda":2} + `), }, { name: "depth 2", run: func(l Logger) { testAllPlogMethods(l.withDepth(2)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func16","message":"always","panda":2} + `), }, { name: "depth 1", run: func(l Logger) { testAllPlogMethods(l.withDepth(1)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.func8","message":"always","panda":2} + `), }, { name: "depth 0", run: func(l Logger) { testAllPlogMethods(l.withDepth(0)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.testAllPlogMethods","message":"always","panda":2} + `), }, { name: "depth -1", run: func(l Logger) { testAllPlogMethods(l.withDepth(-1)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Error","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Warning","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.WarningErr","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Info","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.InfoErr","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Debug","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.DebugErr","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Trace","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.TraceErr","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.All","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Always","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Error","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Warning","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.WarningErr","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Info","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.InfoErr","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Debug","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.DebugErr","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Trace","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.TraceErr","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.All","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Always","message":"always","panda":2} + `), }, { name: "depth -2", run: func(l Logger) { testAllPlogMethods(l.withDepth(-2)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Error","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.warningDepth","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.warningDepth","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.infoDepth","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.infoDepth","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.debugDepth","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.debugDepth","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.traceDepth","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.traceDepth","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Error","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.warningDepth","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.warningDepth","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.infoDepth","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.infoDepth","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.debugDepth","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.debugDepth","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.traceDepth","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.traceDepth","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"always","panda":2} + `), }, { name: "depth -3", run: func(l Logger) { testAllPlogMethods(l.withDepth(-3)) }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Error","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Info","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Info","message":"always","panda":2}`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Error","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"logr@v1.4.2/logr.go:$logr.Logger.Info","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Info","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"zapr@v1.3.0/zapr.go:$zapr.(*zapLogger).Info","message":"always","panda":2} + `), }, { name: "closure", @@ -292,19 +440,19 @@ func TestPlog(t *testing.T) { }() }() }, - want: fmt.Sprintf(` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"always","panda":2} -`, func() string { + want: here.Docf(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestPlog.%[1]s","message":"always","panda":2} + `, func() string { switch { case runtimeVersionSemver.Major == 1 && runtimeVersionSemver.Minor == 21: // Format of string for Go 1.21 @@ -340,19 +488,19 @@ func TestPlog(t *testing.T) { }() }() }, - want: ` -{"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Error","message":"e","panda":2,"error":"some err"} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Warning","message":"w","warning":true,"panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.WarningErr","message":"we","warning":true,"error":"some err","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Info","message":"i","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.InfoErr","message":"ie","error":"some err","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Debug","message":"d","panda":2} -{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.DebugErr","message":"de","error":"some err","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Trace","message":"t","panda":2} -{"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.TraceErr","message":"te","error":"some err","panda":2} -{"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.All","message":"all","panda":2} -{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Always","message":"always","panda":2} -`, + want: here.Doc(` + {"level":"error","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Error","message":"e","panda":2,"error":"some err"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Warning","message":"w","warning":true,"panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.WarningErr","message":"we","warning":true,"error":"some err","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Info","message":"i","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.InfoErr","message":"ie","error":"some err","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Debug","message":"d","panda":2} + {"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.DebugErr","message":"de","error":"some err","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Trace","message":"t","panda":2} + {"level":"trace","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.TraceErr","message":"te","error":"some err","panda":2} + {"level":"all","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.All","message":"all","panda":2} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.pLogger.Always","message":"always","panda":2} + `), }, } for _, test := range tests { diff --git a/internal/plog/testing.go b/internal/plog/testing.go index 121dbc0f7..ab0ca0057 100644 --- a/internal/plog/testing.go +++ b/internal/plog/testing.go @@ -72,10 +72,14 @@ func TestLogger(t *testing.T) (Logger, *bytes.Buffer) { } func TestAuditLogger(t *testing.T) (AuditLogger, *bytes.Buffer) { + return TestAuditLoggerWithConfig(t, AuditLogConfig{LogUsernamesAndGroupNames: true}) +} + +func TestAuditLoggerWithConfig(t *testing.T, cfg AuditLogConfig) (AuditLogger, *bytes.Buffer) { t.Helper() underlyingLogger, logBuf := TestLogger(t) - return &auditLogger{logger: underlyingLogger, cfg: AuditLogConfig{LogUsernamesAndGroupNames: true}}, logBuf + return &auditLogger{logger: underlyingLogger, cfg: cfg}, logBuf } func TestConsoleLogger(t *testing.T, w io.Writer) Logger { diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 2ca645ce1..5d78658ff 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -132,11 +132,15 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation traceSuccess(t, userInfo, true) - r.auditLogger.Audit(auditevent.TokenCredentialRequest, ctx, nil, - "username", userInfo.GetName(), - "groups", userInfo.GetGroups(), - "authenticated", true, - "expires", expires.Format(time.RFC3339)) + r.auditLogger.Audit(auditevent.TokenCredentialRequest, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "username", userInfo.GetName(), + "groups", userInfo.GetGroups(), + "authenticated", true, + "expires", expires.Format(time.RFC3339), + }, + }) return &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ From a308f3f22afffff902124f1969c5e8c24e91cb36 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 12 Nov 2024 11:35:59 -0800 Subject: [PATCH 30/71] audit log: keep key ordering in personalInfo, render nil slices and maps --- internal/plog/plog.go | 153 +++++++++++++++++++++++-------------- internal/plog/plog_test.go | 51 +++++++++++-- 2 files changed, 138 insertions(+), 66 deletions(-) diff --git a/internal/plog/plog.go b/internal/plog/plog.go index a80031997..1f612c70e 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -29,6 +29,8 @@ package plog import ( "context" + "encoding/json" + "fmt" "os" "reflect" "slices" @@ -45,18 +47,6 @@ type SessionIDGetter interface { GetID() string } -// NoSessionPersisted means do not associate this audit event with a session ID. -// The session has not yet "started" and may or may not ever be persisted to permanent storage. -func NoSessionPersisted() SessionIDGetter { - return nil -} - -// NoHTTPRequestAvailable means there is no request context for this audit event. -// Use this when an audit event is emitted from a controller or some other place that does not have a request context. -func NoHTTPRequestAvailable() context.Context { - return nil -} - type AuditParams struct { // ReqCtx may be nil. When possible, pass the http request's context as ReqCtx, // so we may read the audit ID from the context. @@ -172,9 +162,12 @@ func (a *auditLogger) Audit(msg auditevent.Message, p *AuditParams) { allKV = slices.Concat(allKV, []any{"sessionID", sessionID}) } - if p != nil && len(p.PIIKeysAndValues) > 0 { + if p != nil && len(p.PIIKeysAndValues) > 1 { allKV = slices.Concat(allKV, []any{ - "personalInfo", a.nestedPIIKeysAndValues(p.PIIKeysAndValues, a.cfg.LogUsernamesAndGroupNames), + "personalInfo", &piiKeysAndValues{ + kv: p.PIIKeysAndValues, + logUsernamesAndGroupNames: a.cfg.LogUsernamesAndGroupNames, + }, }) } @@ -185,50 +178,6 @@ func (a *auditLogger) Audit(msg auditevent.Message, p *AuditParams) { a.logger.audit(string(msg), allKV...) } -func (a *auditLogger) nestedPIIKeysAndValues(values []any, logUsernamesAndGroupNames bool) map[string]any { - // TODO: This implementation alphabetizes the keys because it builds a map to nest all key/values deeper. Could we keep the original order instead? Do we care? - kvMap := map[string]any{} - var k string - - for i, v := range values { - if i%2 == 0 { - // Interpret even indices (0, 2, 4, etc.) as a key. - // Just remember its value for the next loop iteration. - k = valueAsKey(v) - } else { - // Interpret odd indices as a value. - // Save it using the key from the previous loop iteration. - kvMap[k] = valueAsValue(v, logUsernamesAndGroupNames) - } - } - - return kvMap -} - -func valueAsKey(v any) string { - vStr, ok := v.(string) - if !ok { - // Indicates programmer error that will hopefully be caught by unit tests. - vStr = "cannotCastKeyNameToString" - } - return vStr -} - -func valueAsValue(v any, logUsernamesAndGroupNames bool) any { - if logUsernamesAndGroupNames { - return v // use the original value without redacting - } else { - rt := reflect.TypeOf(v) - if rt.Kind() == reflect.Slice { - // For any slice, replace it by a redacted slice. - return []string{"redacted"} - } else { - // For anything else, just redact it without keeping any hint of the original type. - return "redacted" - } - } -} - // audit is used internally by AuditLogger to print an audit log event to the pLogger's output. func (p pLogger) audit(msg string, keysAndValues ...any) { // Always print log message (klogLevelWarning cannot be suppressed by configuration), @@ -469,3 +418,91 @@ func Fatal(err error, keysAndValues ...any) { globalFlush() os.Exit(1) } + +// piiKeysAndValues can be used to serialize the keys and values as a JSON map without losing their order, +// and optionally redacting values. +type piiKeysAndValues struct { + kv []any + logUsernamesAndGroupNames bool +} + +func (p *piiKeysAndValues) MarshalJSON() ([]byte, error) { + var buf []byte + var k string + var wroteOne bool + + buf = append(buf, '{') + + for i, v := range p.kv { + if i%2 == 0 { + // Interpret even indices (0, 2, 4, etc.) as a key. + // Remember its value for the next loop iteration. + k = p.asJSONKey(v) + } else { + // Interpret odd indices as a value. + // Write it using the key from the previous loop iteration. + // First write a comma if needed. + if wroteOne { + buf = append(buf, ',') + } + buf = append(buf, []byte(k)...) + buf = append(buf, ':') + buf = append(buf, []byte(p.asJSONValue(v))...) + wroteOne = true + } + } + + buf = append(buf, '}') + + return buf, nil +} + +func (p *piiKeysAndValues) asJSONKey(v any) string { + vStr, ok := v.(string) + if !ok { + // Indicates programmer error that will hopefully be caught by unit tests of usages of Audit(). + return `"cannotCastKeyNameToString"` + } + // Encode the string to get proper JSON escaping if needed. + b, err := json.Marshal(vStr) + if err != nil { + // Shouldn't really happen because the argument to Marshal was a string. + return `"cannotMarshalKeyNameToJSON"` + } + return string(b) +} + +func (p *piiKeysAndValues) asJSONValue(v any) string { + rt := reflect.TypeOf(v) // note that rt can be nil when v is nil + if p.logUsernamesAndGroupNames { + // Encode the original value without redacting. + switch { + case v != nil && rt.Kind() == reflect.Slice && reflect.ValueOf(v).IsNil(): + // Handle the special case where v is a nil slice by showing it as an empty slice. + return "[]" + case v != nil && rt.Kind() == reflect.Map && reflect.ValueOf(v).IsNil(): + // Handle the special case where v is a nil map by showing it as an empty map. + return "{}" + default: + b, err := json.Marshal(v) + if err != nil { + // Hopefully this would be caught by unit tests of usages of Audit(). + return `"cannotMarshalValueToJSON"` + } + return string(b) + } + } else { + // Redact the value. + switch { + case rt != nil && rt.Kind() == reflect.Slice: + // Handle the special case where v is a slice by showing it as a slice with redacted values. + return fmt.Sprintf(`["redacted %d values"]`, reflect.ValueOf(v).Len()) + case rt != nil && rt.Kind() == reflect.Map: + // Handle the special case where v is a map by showing it as a map with redacted values. + return fmt.Sprintf(`{"redacted": "redacted %d keys"}`, reflect.ValueOf(v).Len()) + default: + // For anything else, just redact it without worrying about the original type. + return `"redacted"` + } + } +} diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index 7b5b3b4e1..b87daf34f 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -73,22 +73,57 @@ func TestAudit(t *testing.T) { `), }, { - name: "with an even number of PII keys and values", + name: "with an even number of PII keys and values, nests them under personalInfo and preserves their original order, without redacting PII", run: func(a AuditLogger) { - a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "foo", 42}}) + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{ + "username", "ryan", + "groups", []string{"g1", "g2"}, + "int", 42, + "float", 42.75, + `specialJSONChars"👋\`, `hi"👋\`, + "map", map[string]int{"k1": 1, "k2": 2}, + "empty_list", []any{}, + "empty_map", map[string]any{}, + "nil_list", []any(nil), + "nil_map", map[string]any(nil), + "nil_ptr", (*int)(nil), + "nil", nil, + }}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":42,"groups":["g1","g2"],"username":"ryan"}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"username":"ryan","groups":["g1","g2"],"int":42,"float":42.75,"specialJSONChars\"👋\\":"hi\"👋\\","map":{"k1":1,"k2":2},"empty_list":[],"empty_map":{},"nil_list":[],"nil_map":{},"nil_ptr":null,"nil":null}} `), }, { name: "with an even number of PII keys and values and PII configured to be redacted", redactPII: true, run: func(a AuditLogger) { - a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"username", "ryan", "groups", []string{"g1", "g2"}, "foo", 42}}) + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{ + "username", "ryan", + "groups", []string{"g1", "g2"}, + "int", 42, + "float", 42.75, + `specialJSONChars"👋\`, `hi"👋\`, + "map", map[string]int{"k1": 1, "k2": 2}, + "empty_list", []any{}, + "empty_map", map[string]any{}, + "nil_list", []any(nil), + "nil_map", map[string]any(nil), + "nil_ptr", (*int)(nil), + "nil", nil, + }}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":"redacted","groups":["redacted"],"username":"redacted"}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"username":"redacted","groups":["redacted 2 values"],"int":"redacted","float":"redacted","specialJSONChars\"👋\\":"redacted","map":{"redacted":"redacted 2 keys"},"empty_list":["redacted 0 values"],"empty_map":{"redacted":"redacted 0 keys"},"nil_list":["redacted 0 values"],"nil_map":{"redacted":"redacted 0 keys"},"nil_ptr":"redacted","nil":"redacted"}} + `), + }, + { + name: "with an illegal single PII keys and values, quietly ignores it", + run: func(a AuditLogger) { + a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"foo"}}) + }, + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true} `), }, { @@ -106,7 +141,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{42, "foo", "bar", "baz"}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"bar":"baz","cannotCastKeyNameToString":"foo"}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"cannotCastKeyNameToString":"foo","bar":"baz"}} `), }, { @@ -129,7 +164,7 @@ func TestAudit(t *testing.T) { }) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"bat":14,"groups":["g1","g2"],"username":"ryan"},"foo":42,"bar":"baz"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"ryan","groups":["g1","g2"],"bat":14},"foo":42,"bar":"baz"} `), }, { @@ -144,7 +179,7 @@ func TestAudit(t *testing.T) { }) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"bat":"redacted","groups":["redacted"],"username":"redacted"},"foo":42,"bar":"baz"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"redacted","groups":["redacted 2 values"],"bat":"redacted"},"foo":42,"bar":"baz"} `), }, } From e126ee5495f1a67b4c692abf12726fe5990c51c9 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 12 Nov 2024 12:22:41 -0800 Subject: [PATCH 31/71] all callers of Audit() identify which keys may contain PII --- .../downstreamsession/downstream_session.go | 10 ++- .../endpoints/auth/auth_handler_test.go | 86 +++++++++++-------- .../callback/callback_handler_test.go | 52 ++++++----- .../endpoints/token/token_handler.go | 4 +- .../endpoints/token/token_handler_test.go | 28 +++--- internal/registry/credentialrequest/rest.go | 4 +- 6 files changed, 113 insertions(+), 71 deletions(-) diff --git a/internal/federationdomain/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go index 609d8ece8..f94972dac 100644 --- a/internal/federationdomain/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -52,13 +52,15 @@ func NewPinnipedSession( auditLogger.Audit(auditevent.IdentityFromUpstreamIDP, &plog.AuditParams{ ReqCtx: ctx, + PIIKeysAndValues: []any{ + "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, + "upstreamGroups", c.UpstreamIdentity.UpstreamGroups, + }, KeysAndValues: []any{ "upstreamIDPDisplayName", c.IdentityProvider.GetDisplayName(), "upstreamIDPType", c.IdentityProvider.GetSessionProviderType(), "upstreamIDPResourceName", c.IdentityProvider.GetProvider().GetResourceName(), "upstreamIDPResourceUID", c.IdentityProvider.GetProvider().GetResourceUID(), - "upstreamUsername", c.UpstreamIdentity.UpstreamUsername, - "upstreamGroups", c.UpstreamIdentity.UpstreamGroups, }, }) @@ -118,11 +120,13 @@ func NewPinnipedSession( auditLogger.Audit(auditevent.SessionStarted, &plog.AuditParams{ ReqCtx: ctx, Session: c.SessionIDGetter, - KeysAndValues: []any{ + PIIKeysAndValues: []any{ "username", downstreamUsername, "groups", downstreamGroups, "subject", c.UpstreamIdentity.DownstreamSubject, "additionalClaims", c.UpstreamLoginExtras.DownstreamAdditionalClaims, + }, + KeysAndValues: []any{ "warnings", c.UpstreamLoginExtras.Warnings, }, }) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 160c1291f..865392de4 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -1203,16 +1203,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamIDPResourceName": "some-password-granting-oidc-idp", "upstreamIDPResourceUID": "some-password-granting-resource-uid", "upstreamIDPType": "oidc", - "upstreamUsername": "test-oidc-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "personalInfo": map[string]any{ + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "test-oidc-pinniped-username", - "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, - "subject": "https://my-upstream-issuer.com?idpName=some-password-granting-oidc-idp&sub=abc123-some+guid", - "additionalClaims": map[string]any{}, // json: {} - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "test-oidc-pinniped-username", + "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "subject": "https://my-upstream-issuer.com?idpName=some-password-granting-oidc-idp&sub=abc123-some+guid", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, @@ -1288,8 +1292,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamIDPResourceName": "some-password-granting-oidc-idp", "upstreamIDPResourceUID": "some-password-granting-resource-uid", "upstreamIDPType": "oidc", - "upstreamUsername": "test-oidc-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "personalInfo": map[string]any{ + "upstreamUsername": "test-oidc-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }, }), testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", @@ -1409,16 +1415,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamIDPResourceName": "some-ldap-idp", "upstreamIDPResourceUID": "ldap-resource-uid", "upstreamIDPType": "ldap", - "upstreamUsername": "some-ldap-username-from-authenticator", - "upstreamGroups": []any{"group1", "group2", "group3"}, + "personalInfo": map[string]any{ + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "some-ldap-username-from-authenticator", - "groups": []any{"group1", "group2", "group3"}, - "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", - "additionalClaims": nil, // json: null - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-ldap-username-from-authenticator", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, @@ -1479,16 +1489,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamIDPResourceName": "some-ldap-idp", "upstreamIDPResourceUID": "ldap-resource-uid", "upstreamIDPType": "ldap", - "upstreamUsername": "some-ldap-username-from-authenticator", - "upstreamGroups": []any{"group1", "group2", "group3"}, + "personalInfo": map[string]any{ + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "username_prefix:some-ldap-username-from-authenticator", - "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, - "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", - "additionalClaims": nil, // json: null - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "username_prefix:some-ldap-username-from-authenticator", + "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, @@ -1794,16 +1808,20 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "upstreamIDPResourceName": "some-active-directory-idp", "upstreamIDPResourceUID": "active-directory-resource-uid", "upstreamIDPType": "activedirectory", - "upstreamUsername": "some-ldap-username-from-authenticator", - "upstreamGroups": []any{"group1", "group2", "group3"}, + "personalInfo": map[string]any{ + "upstreamUsername": "some-ldap-username-from-authenticator", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "some-ldap-username-from-authenticator", - "groups": []any{"group1", "group2", "group3"}, - "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-active-directory-idp&sub=some-ldap-uid", - "additionalClaims": nil, // json: null - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-ldap-username-from-authenticator", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-active-directory-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index e2aafc691..861a43465 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -290,16 +290,20 @@ func TestCallbackEndpoint(t *testing.T) { "upstreamIDPType": "oidc", "upstreamIDPResourceName": "upstream-oidc-idp-name", "upstreamIDPResourceUID": "upstream-oidc-resource-uid", - "upstreamUsername": "test-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "personalInfo": map[string]any{ + "upstreamUsername": "test-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "test-pinniped-username", - "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, - "subject": "https://my-upstream-issuer.com?idpName=upstream-oidc-idp-name&sub=abc123-some+guid", - "additionalClaims": map[string]any{}, // json: {} - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "test-pinniped-username", + "groups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "subject": "https://my-upstream-issuer.com?idpName=upstream-oidc-idp-name&sub=abc123-some+guid", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, @@ -344,16 +348,20 @@ func TestCallbackEndpoint(t *testing.T) { "upstreamIDPType": "github", "upstreamIDPResourceName": "upstream-github-idp-name", "upstreamIDPResourceUID": "upstream-github-idp-resource-uid", - "upstreamUsername": "some-github-login", - "upstreamGroups": []any{"org1/team1", "org2/team2"}, + "personalInfo": map[string]any{ + "upstreamUsername": "some-github-login", + "upstreamGroups": []any{"org1/team1", "org2/team2"}, + }, }), testutil.WantAuditLog("Session Started", map[string]any{ - "sessionID": sessionID, - "username": "some-github-login", - "groups": []any{"org1/team1", "org2/team2"}, - "subject": "https://github.com?idpName=upstream-github-idp-name&sub=some-github-login", - "additionalClaims": nil, // json: null - "warnings": []any{}, // json: [] + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-github-login", + "groups": []any{"org1/team1", "org2/team2"}, + "subject": "https://github.com?idpName=upstream-github-idp-name&sub=some-github-login", + "additionalClaims": map[string]any{}, // json: {} + }, }), } }, @@ -1788,8 +1796,10 @@ func TestCallbackEndpoint(t *testing.T) { "upstreamIDPType": "oidc", "upstreamIDPResourceName": "upstream-oidc-idp-name", "upstreamIDPResourceUID": "upstream-oidc-resource-uid", - "upstreamUsername": "test-pinniped-username", - "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + "personalInfo": map[string]any{ + "upstreamUsername": "test-pinniped-username", + "upstreamGroups": []any{"test-pinniped-group-0", "test-pinniped-group-1"}, + }, }), testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", @@ -1821,8 +1831,10 @@ func TestCallbackEndpoint(t *testing.T) { "upstreamIDPType": "github", "upstreamIDPResourceName": "upstream-github-idp-name", "upstreamIDPResourceUID": "upstream-github-idp-resource-uid", - "upstreamUsername": "some-github-login", - "upstreamGroups": []any{"org1/team1", "org2/team2"}, + "personalInfo": map[string]any{ + "upstreamUsername": "some-github-login", + "upstreamGroups": []any{"org1/team1", "org2/team2"}, + }, }), testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "reason": "configured identity policy rejected this authentication: authentication was rejected by a configured policy", diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 46f3b7c05..b52531190 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -195,7 +195,7 @@ func upstreamRefresh( auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, &plog.AuditParams{ ReqCtx: ctx, Session: accessRequest, - KeysAndValues: []any{ + PIIKeysAndValues: []any{ "upstreamUsername", refreshedIdentity.UpstreamUsername, "upstreamGroups", refreshedIdentity.UpstreamGroups, }, @@ -250,7 +250,7 @@ func upstreamRefresh( auditLogger.Audit(auditevent.SessionRefreshed, &plog.AuditParams{ ReqCtx: ctx, Session: accessRequest, - KeysAndValues: []any{ + PIIKeysAndValues: []any{ "username", oldTransformedUsername, // not allowed to change above so must be the same as old "groups", refreshedTransformedGroups, "subject", previousIdentity.DownstreamSubject}, diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index db196ddb2..9fdcfa31c 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -2340,18 +2340,22 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ - "sessionID": sessionID, - "upstreamGroups": []any{}, - "upstreamUsername": "some-username", + "sessionID": sessionID, + "personalInfo": map[string]any{ + "upstreamGroups": []any{}, + "upstreamUsername": "some-username", + }, }), testutil.WantAuditLog("Session Refreshed", map[string]any{ "sessionID": sessionID, - "username": "some-username", - "groups": []any{ - "group1", - "groups2", + "personalInfo": map[string]any{ + "username": "some-username", + "groups": []any{ + "group1", + "groups2", + }, + "subject": "https://issuer?sub=some-subject", }, - "subject": "https://issuer?sub=some-subject", }), } }, @@ -2533,9 +2537,11 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ - "sessionID": sessionID, - "upstreamGroups": []any{}, - "upstreamUsername": "some-username", + "sessionID": sessionID, + "personalInfo": map[string]any{ + "upstreamGroups": []any{}, + "upstreamUsername": "some-username", + }, }), testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ "sessionID": sessionID, diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 5d78658ff..29b411c60 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -134,9 +134,11 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation r.auditLogger.Audit(auditevent.TokenCredentialRequest, &plog.AuditParams{ ReqCtx: ctx, - KeysAndValues: []any{ + PIIKeysAndValues: []any{ "username", userInfo.GetName(), "groups", userInfo.GetGroups(), + }, + KeysAndValues: []any{ "authenticated", true, "expires", expires.Format(time.RFC3339), }, From 37e12b40243353cb829a5642981e1bb0427b938a Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 12 Nov 2024 17:23:17 -0600 Subject: [PATCH 32/71] Start backfilling some audit unit tests in post_login_handler --- .../login/post_login_handler_test.go | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 62ab8ebf9..24e9e5a15 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -329,6 +329,7 @@ func TestPostLoginEndpoint(t *testing.T) { // is stored, so it is possible with an LDAP upstream to store objects and then return an error to // the client anyway (which makes the stored objects useless, but oh well). wantUnnecessaryStoredRecords int + wantAuditLogs func(sessionID string) []testutil.WantedAuditLog }{ { name: "happy LDAP login", @@ -352,6 +353,30 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPType": "ldap", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "personalInfo": map[string]any{ + "upstreamUsername": "some-mapped-ldap-username", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-mapped-ldap-username", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, + }), + } + }, }, { name: "happy LDAP login with identity transformations which modify the username and group names", @@ -380,6 +405,30 @@ func TestPostLoginEndpoint(t *testing.T) { happyLDAPUsernameFromAuthenticator, happyLDAPGroups, ), + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPType": "ldap", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "personalInfo": map[string]any{ + "upstreamUsername": "some-mapped-ldap-username", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "username_prefix:some-mapped-ldap-username", + "groups": []any{"groups_prefix:group1", "groups_prefix:group2", "groups_prefix:group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, + }), + } + }, }, { name: "happy LDAP login with dynamic client", @@ -427,6 +476,30 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-active-directory-idp", + "upstreamIDPType": "activedirectory", + "upstreamIDPResourceName": "some-active-directory-idp", + "upstreamIDPResourceUID": "active-directory-resource-uid", + "personalInfo": map[string]any{ + "upstreamUsername": "some-mapped-ldap-username", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-mapped-ldap-username", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-active-directory-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, + }), + } + }, }, { name: "happy AD login with identity transformations which modify the username and group names", @@ -1147,7 +1220,7 @@ func TestPostLoginEndpoint(t *testing.T) { rsp := httptest.NewRecorder() - auditLogger, _ := plog.TestAuditLogger(t) + auditLogger, actualAuditLog := plog.TestAuditLogger(t) subject := NewPostHandler(downstreamIssuer, tt.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, auditLogger) @@ -1165,12 +1238,13 @@ func TestPostLoginEndpoint(t *testing.T) { actualLocation := rsp.Header().Get("Location") + var sessionID string switch { case tt.wantRedirectLocationRegexp != "": // Expecting a success redirect to the client. require.Equal(t, tt.wantBodyString, rsp.Body.String()) require.Len(t, rsp.Header().Values("Location"), 1) - _ = oidctestutil.RequireAuthCodeRegexpMatch( + sessionID = oidctestutil.RequireAuthCodeRegexpMatch( t, actualLocation, tt.wantRedirectLocationRegexp, @@ -1206,7 +1280,7 @@ func TestPostLoginEndpoint(t *testing.T) { // Expecting the body of the response to be a html page with a form (for "response_mode=form_post"). _, hasLocationHeader := rsp.Header()["Location"] require.False(t, hasLocationHeader) - _ = oidctestutil.RequireAuthCodeRegexpMatch( + sessionID = oidctestutil.RequireAuthCodeRegexpMatch( t, rsp.Body.String(), tt.wantBodyFormResponseRegexp, @@ -1230,6 +1304,10 @@ func TestPostLoginEndpoint(t *testing.T) { require.Failf(t, "test should have expected a redirect or form body", "actual location was %q", actualLocation) } + + if test.wantAuditLogs != nil { + testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), actualAuditLog.String()) + } }) } } From e21e1326b711e74a77146c56ebe1deecac8fb83b Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 12 Nov 2024 14:08:36 -0800 Subject: [PATCH 33/71] tokencredentialrequest audit logs successful responses Co-authored-by: Joshua Casey --- internal/concierge/apiserver/apiserver.go | 9 +- internal/registry/credentialrequest/rest.go | 6 +- .../registry/credentialrequest/rest_test.go | 122 +++++++++++++----- 3 files changed, 101 insertions(+), 36 deletions(-) diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index a147efb8e..b58a4b2c2 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -15,6 +15,7 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" utilversion "k8s.io/apiserver/pkg/util/version" + "k8s.io/utils/clock" "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/controllerinit" @@ -83,7 +84,13 @@ func (c completedConfig) New() (*PinnipedServer, error) { for _, f := range []func() (schema.GroupVersionResource, rest.Storage){ func() (schema.GroupVersionResource, rest.Storage) { tokenCredReqGVR := c.ExtraConfig.LoginConciergeGroupVersion.WithResource("tokencredentialrequests") - tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource(), c.ExtraConfig.AuditLogger) + tokenCredStorage := credentialrequest.NewREST( + c.ExtraConfig.Authenticator, + c.ExtraConfig.Issuer, + tokenCredReqGVR.GroupResource(), + c.ExtraConfig.AuditLogger, + clock.RealClock{}, + ) return tokenCredReqGVR, tokenCredStorage }, func() (schema.GroupVersionResource, rest.Storage) { diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 29b411c60..623915088 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -18,6 +18,7 @@ import ( "k8s.io/apiserver/pkg/authentication/user" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/utils/clock" "k8s.io/utils/trace" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" @@ -38,12 +39,14 @@ func NewREST( issuer clientcertissuer.ClientCertIssuer, resource schema.GroupResource, auditLogger plog.AuditLogger, + clock clock.Clock, ) *REST { return &REST{ authenticator: authenticator, issuer: issuer, tableConvertor: rest.NewDefaultTableConvertor(resource), auditLogger: auditLogger, + clock: clock, } } @@ -52,6 +55,7 @@ type REST struct { issuer clientcertissuer.ClientCertIssuer tableConvertor rest.TableConvertor auditLogger plog.AuditLogger + clock clock.Clock } // Assert that our *REST implements all the optional interfaces that we expect it to implement. @@ -123,7 +127,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation } // this timestamp should be returned from IssueClientCertPEM but this is a safe approximation - expires := metav1.NewTime(time.Now().UTC().Add(clientCertificateTTL)) + expires := metav1.NewTime(r.clock.Now().UTC().Add(clientCertificateTTL)) certPEM, keyPEM, err := r.issuer.IssueClientCertPEM(userInfo.GetName(), userInfo.GetGroups(), clientCertificateTTL) if err != nil { traceFailureWithError(t, "cert issuer", err) diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index c1c8e5e10..0d091c7fd 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -4,6 +4,7 @@ package credentialrequest import ( + "bytes" "context" "errors" "fmt" @@ -18,10 +19,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/authentication/user" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/klog/v2" + "k8s.io/utils/clock" + clocktesting "k8s.io/utils/clock/testing" "k8s.io/utils/ptr" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" @@ -33,7 +37,7 @@ import ( ) func TestNew(t *testing.T) { - r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, nil) + r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, nil, clock.RealClock{}) require.NotNil(t, r) require.False(t, r.NamespaceScoped()) require.Equal(t, []string{"pinniped"}, r.Categories()) @@ -71,6 +75,10 @@ func TestCreate(t *testing.T) { var logger *testutil.TranscriptLogger var originalKLogLevel klog.Level var auditLogger plog.AuditLogger + var actualAuditLog *bytes.Buffer + var frozenNow time.Time + var frozenClock *clocktesting.FakeClock + var wantAuditLog []testutil.WantedAuditLog it.Before(func() { r = require.New(t) @@ -80,10 +88,13 @@ func TestCreate(t *testing.T) { originalKLogLevel = testutil.GetGlobalKlogLevel() // trace.Log() utility will only log at level 2 or above, so set that for this test. testutil.SetGlobalKlogLevel(t, 2) //nolint:staticcheck // old test of code using trace.Log() - auditLogger, _ = plog.TestAuditLogger(t) + auditLogger, actualAuditLog = plog.TestAuditLogger(t) + frozenNow = time.Date(2024, time.September, 12, 4, 25, 56, 778899, time.UTC) + frozenClock = clocktesting.NewFakeClock(frozenNow) }) it.After(func() { + testutil.CompareAuditLogs(t, wantAuditLog, actualAuditLog.String()) klog.ClearLogger() testutil.SetGlobalKlogLevel(t, originalKLogLevel) //nolint:staticcheck // old test of code using trace.Log() ctrl.Finish() @@ -106,28 +117,36 @@ func TestCreate(t *testing.T) { 5*time.Minute, ).Return([]byte("test-cert"), []byte("test-key"), nil) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) r.NoError(err) r.IsType(&loginapi.TokenCredentialRequest{}, response) - expires := response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp - r.NotNil(expires) - r.InDelta(time.Now().Add(5*time.Minute).Unix(), expires.Unix(), 5) - response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp = metav1.Time{} - r.Equal(response, &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ Credential: &loginapi.ClusterCredential{ - ExpirationTimestamp: metav1.Time{}, + ExpirationTimestamp: metav1.NewTime(frozenNow.Add(5 * time.Minute).UTC()), ClientCertificateData: "test-cert", ClientKeyData: "test-key", }, }, }) + requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:true`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ + "auditID": "fake-audit-id", + "authenticated": true, + "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "personalInfo": map[string]any{ + "username": "test-user", + "groups": []any{"test-group-1", "test-group-2"}, + }, + }), + } }) it("CreateFailsWithValidTokenWhenCertIssuerFails", func() { @@ -145,9 +164,9 @@ func TestCreate(t *testing.T) { IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil, fmt.Errorf("some certificate authority error")) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`) }) @@ -158,9 +177,9 @@ func TestCreate(t *testing.T) { requestAuthenticator := mockcredentialrequest.NewMockTokenCredentialRequestAuthenticator(ctrl) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:false`) @@ -173,9 +192,9 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(nil, errors.New("some webhook error")) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"failure" failureType:token authentication,msg:some webhook error`) @@ -188,9 +207,9 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(&user.DefaultInfo{Name: ""}, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:false`) @@ -207,9 +226,9 @@ func TestCreate(t *testing.T) { Groups: []string{"test-group-1", "test-group-2"}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"success" userID:test-uid,hasExtra:false,authenticated:false`) @@ -226,9 +245,9 @@ func TestCreate(t *testing.T) { Extra: map[string][]string{"test-key": {"test-val-1", "test-val-2"}}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) - response, err := callCreate(context.Background(), storage, req) + response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) requireOneLogStatement(r, logger, `"success" userID:,hasExtra:true,authenticated:false`) @@ -236,7 +255,7 @@ func TestCreate(t *testing.T) { it("CreateFailsWhenGivenTheWrongInputType", func() { notACredentialRequest := runtime.Unknown{} - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( genericapirequest.NewContext(), ¬ACredentialRequest, rest.ValidateAllObjectFunc, @@ -247,8 +266,8 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) - response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock) + response, err := callCreate(storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ Token: "", })) @@ -258,7 +277,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenValidationFails", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock) response, err := storage.Create( context.Background(), validCredentialRequest(), @@ -278,9 +297,12 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger) + fakeReqContext := audit.WithAuditContext(context.Background()) + audit.WithAuditID(fakeReqContext, "fake-audit-id") + + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger, frozenClock) response, err := storage.Create( - context.Background(), + fakeReqContext, req, func(ctx context.Context, obj runtime.Object) error { credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest) @@ -290,6 +312,18 @@ func TestCreate(t *testing.T) { &metav1.CreateOptions{}) r.NoError(err) r.NotEmpty(response) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ + "auditID": "fake-audit-id", + "authenticated": true, + "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "personalInfo": map[string]any{ + "username": "test-user", + "groups": []any{}, + }, + }), + } }) it("CreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken", func() { @@ -299,11 +333,16 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger, frozenClock) + + fakeReqContext := audit.WithAuditContext(context.Background()) + audit.WithAuditID(fakeReqContext, "fake-audit-id") + validationFunctionWasCalled := false var validationFunctionSawTokenValue string + response, err := storage.Create( - context.Background(), + fakeReqContext, req, func(ctx context.Context, obj runtime.Object) error { credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest) @@ -316,10 +355,22 @@ func TestCreate(t *testing.T) { r.NotEmpty(response) r.True(validationFunctionWasCalled) r.Empty(validationFunctionSawTokenValue) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ + "auditID": "fake-audit-id", + "authenticated": true, + "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "personalInfo": map[string]any{ + "username": "test-user", + "groups": []any{}, + }, + }), + } }) it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( genericapirequest.NewContext(), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -333,7 +384,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenNamespaceIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -352,9 +403,12 @@ func requireOneLogStatement(r *require.Assertions, logger *testutil.TranscriptLo r.Contains(transcript[0].Message, messageContains) } -func callCreate(ctx context.Context, storage *REST, obj runtime.Object) (runtime.Object, error) { +func callCreate(storage *REST, obj runtime.Object) (runtime.Object, error) { + fakeReqContext := audit.WithAuditContext(context.Background()) + audit.WithAuditID(fakeReqContext, "fake-audit-id") + return storage.Create( - ctx, + fakeReqContext, obj, rest.ValidateAllObjectFunc, &metav1.CreateOptions{ From 438ca437ec741cf635c6ea119ce056b635309a85 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 12 Nov 2024 16:13:41 -0800 Subject: [PATCH 34/71] tokencredentialrequest audit logs failed requests --- internal/auditevent/audit_event.go | 33 ++-- internal/registry/credentialrequest/rest.go | 125 +++++++------ .../registry/credentialrequest/rest_test.go | 171 +++++++++++++----- 3 files changed, 206 insertions(+), 123 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index e8b0caf48..1852dd8bd 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -12,21 +12,24 @@ import ( type Message string const ( - HTTPRequestReceived Message = "HTTP Request Received" - HTTPRequestCompleted Message = "HTTP Request Completed" - HTTPRequestParameters Message = "HTTP Request Parameters" - HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" - UsingUpstreamIDP Message = "Using Upstream IDP" - AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" - IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" - IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" - SessionStarted Message = "Session Started" - SessionRefreshed Message = "Session Refreshed" - AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" - UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential - SessionGarbageCollected Message = "Session Garbage Collected" - TokenCredentialRequest Message = "TokenCredentialRequest" //nolint:gosec // this is not a credential - UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" + HTTPRequestReceived Message = "HTTP Request Received" + HTTPRequestCompleted Message = "HTTP Request Completed" + HTTPRequestParameters Message = "HTTP Request Parameters" + HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" + UsingUpstreamIDP Message = "Using Upstream IDP" + AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" + IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" + IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" + SessionStarted Message = "Session Started" + SessionRefreshed Message = "Session Refreshed" + AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" + UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential + SessionGarbageCollected Message = "Session Garbage Collected" + UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" + TokenCredentialRequestAuthenticatedUser Message = "TokenCredentialRequest Authenticated User" //nolint:gosec // this is not a credential + TokenCredentialRequestAuthenticationFailed Message = "TokenCredentialRequest Authentication Failed" //nolint:gosec // this is not a credential + TokenCredentialRequestUnexpectedError Message = "TokenCredentialRequest Unexpected Error" //nolint:gosec // this is not a credential + TokenCredentialRequestUnsupportedUserInfo Message = "TokenCredentialRequest Unsupported UserInfo" //nolint:gosec // this is not a credential ) // SanitizeParams can be used to redact all params not included in the allowedKeys set. diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 623915088..cf987f770 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -6,6 +6,7 @@ package credentialrequest import ( "context" + "errors" "fmt" "time" @@ -19,7 +20,6 @@ import ( genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/utils/clock" - "k8s.io/utils/trace" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/auditevent" @@ -105,46 +105,78 @@ func (*REST) GetSingularName() string { } func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { - t := trace.FromContext(ctx).Nest("create", trace.Field{ - Key: "kind", - Value: "TokenCredentialRequest", - }) - defer t.Log() - - credentialRequest, err := validateRequest(ctx, obj, createValidation, options, t) + credentialRequest, err := validateRequest(ctx, obj, createValidation, options) if err != nil { + // Bad requests are not audit logged because the Kubernetes audit log will show the response's status error code. + plog.DebugErr("TokenCredentialRequest request object validation error", err) return nil, err } userInfo, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest) if err != nil { - traceFailureWithError(t, "token authentication", err) - return failureResponse(), nil + r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "reason", "authenticator returned an error", + "err", err.Error(), + "authenticator", credentialRequest.Spec.Authenticator, + }, + }) + return authenticationFailedResponse(), nil } - if ok := isUserInfoValid(userInfo); !ok { - traceSuccess(t, userInfo, false) - return failureResponse(), nil + + if userInfo == nil { + r.auditLogger.Audit(auditevent.TokenCredentialRequestAuthenticationFailed, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "reason", "auth rejected by authenticator", + "authenticator", credentialRequest.Spec.Authenticator, + }, + }) + return authenticationFailedResponse(), nil + } + + if err = validateUserInfo(userInfo); err != nil { + r.auditLogger.Audit(auditevent.TokenCredentialRequestUnsupportedUserInfo, &plog.AuditParams{ + ReqCtx: ctx, + PIIKeysAndValues: []any{ + "userInfoName", userInfo.GetName(), + "userInfoUID", userInfo.GetUID(), + }, + KeysAndValues: []any{ + "userInfoExtrasCount", len(userInfo.GetExtra()), + "reason", "unsupported value in userInfo returned by authenticator", + "err", err.Error(), + "authenticator", credentialRequest.Spec.Authenticator, + }, + }) + return authenticationFailedResponse(), nil } // this timestamp should be returned from IssueClientCertPEM but this is a safe approximation expires := metav1.NewTime(r.clock.Now().UTC().Add(clientCertificateTTL)) certPEM, keyPEM, err := r.issuer.IssueClientCertPEM(userInfo.GetName(), userInfo.GetGroups(), clientCertificateTTL) if err != nil { - traceFailureWithError(t, "cert issuer", err) - return failureResponse(), nil + r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "reason", "cert issuer returned an error", + "err", err.Error(), + "authenticator", credentialRequest.Spec.Authenticator, + }, + }) + return authenticationFailedResponse(), nil } - traceSuccess(t, userInfo, true) - - r.auditLogger.Audit(auditevent.TokenCredentialRequest, &plog.AuditParams{ + r.auditLogger.Audit(auditevent.TokenCredentialRequestAuthenticatedUser, &plog.AuditParams{ ReqCtx: ctx, PIIKeysAndValues: []any{ "username", userInfo.GetName(), "groups", userInfo.GetGroups(), }, KeysAndValues: []any{ - "authenticated", true, - "expires", expires.Format(time.RFC3339), + "issuedClientCertExpires", expires.Format(time.RFC3339), + "authenticator", credentialRequest.Spec.Authenticator, }, }) @@ -159,15 +191,13 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation }, nil } -func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions, t *trace.Trace) (*loginapi.TokenCredentialRequest, error) { +func validateRequest(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (*loginapi.TokenCredentialRequest, error) { credentialRequest, ok := obj.(*loginapi.TokenCredentialRequest) if !ok { - traceValidationFailure(t, "not a TokenCredentialRequest") return nil, apierrors.NewBadRequest(fmt.Sprintf("not a TokenCredentialRequest: %#v", obj)) } if len(credentialRequest.Spec.Token) == 0 { - traceValidationFailure(t, "token must be supplied") errs := field.ErrorList{field.Required(field.NewPath("spec", "token", "value"), "token must be supplied")} return nil, apierrors.NewInvalid(loginapi.Kind(credentialRequest.Kind), credentialRequest.Name, errs) } @@ -175,14 +205,12 @@ func validateRequest(ctx context.Context, obj runtime.Object, createValidation r // just a sanity check, not sure how to honor a dry run on a virtual API if options != nil { if len(options.DryRun) != 0 { - traceValidationFailure(t, "dryRun not supported") errs := field.ErrorList{field.NotSupported(field.NewPath("dryRun"), options.DryRun, []string(nil))} return nil, apierrors.NewInvalid(loginapi.Kind(credentialRequest.Kind), credentialRequest.Name, errs) } } if namespace := genericapirequest.NamespaceValue(ctx); len(namespace) != 0 { - traceValidationFailure(t, "namespace is not allowed") return nil, apierrors.NewBadRequest(fmt.Sprintf("namespace is not allowed on TokenCredentialRequest: %v", namespace)) } @@ -195,7 +223,6 @@ func validateRequest(ctx context.Context, obj runtime.Object, createValidation r requestForValidation := obj.DeepCopyObject() requestForValidation.(*loginapi.TokenCredentialRequest).Spec.Token = "" if err := createValidation(ctx, requestForValidation); err != nil { - traceFailureWithError(t, "validation webhook", err) return nil, err } } @@ -203,48 +230,20 @@ func validateRequest(ctx context.Context, obj runtime.Object, createValidation r return credentialRequest, nil } -func isUserInfoValid(userInfo user.Info) bool { +func validateUserInfo(userInfo user.Info) error { switch { - case userInfo == nil, // must be non-nil - len(userInfo.GetName()) == 0, // must have a username, groups are optional - len(userInfo.GetUID()) != 0, // certs cannot assert UID - len(userInfo.GetExtra()) != 0: // certs cannot assert extra - return false - + case len(userInfo.GetName()) == 0: + return errors.New("empty username is not allowed") + case len(userInfo.GetUID()) != 0: + return errors.New("UIDs are not supported") // certs cannot assert UID + case len(userInfo.GetExtra()) != 0: + return errors.New("extras are not supported") // certs cannot assert extra default: - return true + return nil } } -func traceSuccess(t *trace.Trace, userInfo user.Info, authenticated bool) { - userID := "" - hasExtra := false - if userInfo != nil { - userID = userInfo.GetUID() - hasExtra = len(userInfo.GetExtra()) > 0 - } - t.Step("success", - trace.Field{Key: "userID", Value: userID}, - trace.Field{Key: "hasExtra", Value: hasExtra}, - trace.Field{Key: "authenticated", Value: authenticated}, - ) -} - -func traceValidationFailure(t *trace.Trace, msg string) { - t.Step("failure", - trace.Field{Key: "failureType", Value: "request validation"}, - trace.Field{Key: "msg", Value: msg}, - ) -} - -func traceFailureWithError(t *trace.Trace, failureType string, err error) { - t.Step("failure", - trace.Field{Key: "failureType", Value: failureType}, - trace.Field{Key: "msg", Value: err.Error()}, - ) -} - -func failureResponse() *loginapi.TokenCredentialRequest { +func authenticationFailedResponse() *loginapi.TokenCredentialRequest { m := "authentication failed" return &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 0d091c7fd..564bbf7eb 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/go-logr/logr" "github.com/sclevine/spec" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -23,7 +23,6 @@ import ( "k8s.io/apiserver/pkg/authentication/user" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" - "k8s.io/klog/v2" "k8s.io/utils/clock" clocktesting "k8s.io/utils/clock/testing" "k8s.io/utils/ptr" @@ -72,8 +71,6 @@ func TestCreate(t *testing.T) { spec.Run(t, "create", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions var ctrl *gomock.Controller - var logger *testutil.TranscriptLogger - var originalKLogLevel klog.Level var auditLogger plog.AuditLogger var actualAuditLog *bytes.Buffer var frozenNow time.Time @@ -83,11 +80,6 @@ func TestCreate(t *testing.T) { it.Before(func() { r = require.New(t) ctrl = gomock.NewController(t) - logger = testutil.NewTranscriptLogger(t) //nolint:staticcheck // old test with lots of log statements - klog.SetLogger(logr.New(logger)) // this is unfortunately a global logger, so can't run these tests in parallel :( - originalKLogLevel = testutil.GetGlobalKlogLevel() - // trace.Log() utility will only log at level 2 or above, so set that for this test. - testutil.SetGlobalKlogLevel(t, 2) //nolint:staticcheck // old test of code using trace.Log() auditLogger, actualAuditLog = plog.TestAuditLogger(t) frozenNow = time.Date(2024, time.September, 12, 4, 25, 56, 778899, time.UTC) frozenClock = clocktesting.NewFakeClock(frozenNow) @@ -95,8 +87,6 @@ func TestCreate(t *testing.T) { it.After(func() { testutil.CompareAuditLogs(t, wantAuditLog, actualAuditLog.String()) - klog.ClearLogger() - testutil.SetGlobalKlogLevel(t, originalKLogLevel) //nolint:staticcheck // old test of code using trace.Log() ctrl.Finish() }) @@ -134,13 +124,15 @@ func TestCreate(t *testing.T) { }, }) - requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:true`) - wantAuditLog = []testutil.WantedAuditLog{ - testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ - "auditID": "fake-audit-id", - "authenticated": true, - "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC "personalInfo": map[string]any{ "username": "test-user", "groups": []any{"test-group-1", "test-group-2"}, @@ -168,7 +160,19 @@ func TestCreate(t *testing.T) { response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "cert issuer returned an error", + "err": "some certificate authority error", + }), + } }) it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsNilUser", func() { @@ -182,7 +186,18 @@ func TestCreate(t *testing.T) { response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:false`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Authentication Failed", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "auth rejected by authenticator", + }), + } }) it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails", func() { @@ -197,7 +212,19 @@ func TestCreate(t *testing.T) { response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"failure" failureType:token authentication,msg:some webhook error`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "authenticator returned an error", + "err": "some webhook error", + }), + } }) it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() { @@ -205,14 +232,31 @@ func TestCreate(t *testing.T) { requestAuthenticator := mockcredentialrequest.NewMockTokenCredentialRequestAuthenticator(ctrl) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). - Return(&user.DefaultInfo{Name: ""}, nil) + Return(&user.DefaultInfo{Name: "", UID: "test-uid"}, nil) storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:false`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "unsupported value in userInfo returned by authenticator", + "err": "empty username is not allowed", + "userInfoExtrasCount": float64(0), + "personalInfo": map[string]any{ + "userInfoName": "", + "userInfoUID": "test-uid", + }, + }), + } }) it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAUserWithUID", func() { @@ -231,7 +275,24 @@ func TestCreate(t *testing.T) { response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"success" userID:test-uid,hasExtra:false,authenticated:false`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "unsupported value in userInfo returned by authenticator", + "err": "UIDs are not supported", + "userInfoExtrasCount": float64(0), + "personalInfo": map[string]any{ + "userInfoName": "test-user", + "userInfoUID": "test-uid", + }, + }), + } }) it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAUserWithExtra", func() { @@ -250,7 +311,24 @@ func TestCreate(t *testing.T) { response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) - requireOneLogStatement(r, logger, `"success" userID:,hasExtra:true,authenticated:false`) + + wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "reason": "unsupported value in userInfo returned by authenticator", + "err": "extras are not supported", + "userInfoExtrasCount": float64(1), + "personalInfo": map[string]any{ + "userInfoName": "test-user", + "userInfoUID": "", + }, + }), + } }) it("CreateFailsWhenGivenTheWrongInputType", func() { @@ -262,7 +340,6 @@ func TestCreate(t *testing.T) { &metav1.CreateOptions{}) requireAPIError(t, response, err, apierrors.IsBadRequest, "not a TokenCredentialRequest") - requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:not a TokenCredentialRequest`) }) it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { @@ -273,7 +350,6 @@ func TestCreate(t *testing.T) { requireAPIError(t, response, err, apierrors.IsInvalid, `.pinniped.dev "request name" is invalid: spec.token.value: Required value: token must be supplied`) - requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:token must be supplied`) }) it("CreateFailsWhenValidationFails", func() { @@ -287,7 +363,6 @@ func TestCreate(t *testing.T) { &metav1.CreateOptions{}) r.Nil(response) r.EqualError(err, "some validation error") - requireOneLogStatement(r, logger, `"failure" failureType:validation webhook,msg:some validation error`) }) it("CreateDoesNotAllowValidationFunctionToMutateRequest", func() { @@ -314,10 +389,14 @@ func TestCreate(t *testing.T) { r.NotEmpty(response) wantAuditLog = []testutil.WantedAuditLog{ - testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ - "auditID": "fake-audit-id", - "authenticated": true, - "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC "personalInfo": map[string]any{ "username": "test-user", "groups": []any{}, @@ -357,10 +436,14 @@ func TestCreate(t *testing.T) { r.Empty(validationFunctionSawTokenValue) wantAuditLog = []testutil.WantedAuditLog{ - testutil.WantAuditLog("TokenCredentialRequest", map[string]any{ - "auditID": "fake-audit-id", - "authenticated": true, - "expires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ + "auditID": "fake-audit-id", + "authenticator": map[string]any{ + "apiGroup": "fake-api-group.com", + "kind": "FakeAuthenticatorKind", + "name": "fake-authenticator-name", + }, + "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC "personalInfo": map[string]any{ "username": "test-user", "groups": []any{}, @@ -380,7 +463,6 @@ func TestCreate(t *testing.T) { requireAPIError(t, response, err, apierrors.IsInvalid, `.pinniped.dev "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`) - requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`) }) it("CreateFailsWhenNamespaceIsNotEmpty", func() { @@ -391,18 +473,10 @@ func TestCreate(t *testing.T) { &metav1.CreateOptions{}) requireAPIError(t, response, err, apierrors.IsBadRequest, `namespace is not allowed on TokenCredentialRequest: some-ns`) - requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:namespace is not allowed`) }) }, spec.Sequential()) } -func requireOneLogStatement(r *require.Assertions, logger *testutil.TranscriptLogger, messageContains string) { - transcript := logger.Transcript() - r.Len(transcript, 1) - r.Equal("info", transcript[0].Level) - r.Contains(transcript[0].Message, messageContains) -} - func callCreate(storage *REST, obj runtime.Object) (runtime.Object, error) { fakeReqContext := audit.WithAuditContext(context.Background()) audit.WithAuditID(fakeReqContext, "fake-audit-id") @@ -421,7 +495,14 @@ func validCredentialRequest() *loginapi.TokenCredentialRequest { } func validCredentialRequestWithToken(token string) *loginapi.TokenCredentialRequest { - return credentialRequest(loginapi.TokenCredentialRequestSpec{Token: token}) + return credentialRequest(loginapi.TokenCredentialRequestSpec{ + Token: token, + Authenticator: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("fake-api-group.com"), + Kind: "FakeAuthenticatorKind", + Name: "fake-authenticator-name", + }, + }) } func credentialRequest(spec loginapi.TokenCredentialRequestSpec) *loginapi.TokenCredentialRequest { From de722332b156248656d4c430e9b49173ee46a1fc Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 13 Nov 2024 12:29:23 -0600 Subject: [PATCH 35/71] Add audit logging to post_login_handler --- internal/auditevent/audit_event.go | 1 + .../endpoints/auth/auth_handler.go | 3 + .../endpoints/callback/callback_handler.go | 2 + .../endpoints/login/post_login_handler.go | 19 +++ .../login/post_login_handler_test.go | 134 +++++++++++++++++- 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index 1852dd8bd..b03f82cca 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -30,6 +30,7 @@ const ( TokenCredentialRequestAuthenticationFailed Message = "TokenCredentialRequest Authentication Failed" //nolint:gosec // this is not a credential TokenCredentialRequestUnexpectedError Message = "TokenCredentialRequest Unexpected Error" //nolint:gosec // this is not a credential TokenCredentialRequestUnsupportedUserInfo Message = "TokenCredentialRequest Unsupported UserInfo" //nolint:gosec // this is not a credential + IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" //nolint:gosec // this is not a credential ) // SanitizeParams can be used to redact all params not included in the allowedKeys set. diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 93bffc61f..7baa259e9 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -264,6 +264,9 @@ func (h *authorizeHandler) authorizeWithoutBrowser( return err } + // TODO: Perhaps add audit event "Incorrect Username Or Password"? + // See "post_login_handler" for example + session, err := downstreamsession.NewPinnipedSession(r.Context(), h.auditLogger, &downstreamsession.SessionConfig{ UpstreamIdentity: identity, UpstreamLoginExtras: loginExtras, diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 79e149e50..f8a84b35a 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -45,6 +45,8 @@ func NewHandler( return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found") } + // TODO: Add audit event "auditevent.UsingUpstreamIDP" + downstreamAuthParams, err := url.ParseQuery(decodedState.AuthParams) if err != nil { plog.Error("error reading state downstream auth params", err) diff --git a/internal/federationdomain/endpoints/login/post_login_handler.go b/internal/federationdomain/endpoints/login/post_login_handler.go index 176782f00..3dfdad220 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler.go +++ b/internal/federationdomain/endpoints/login/post_login_handler.go @@ -10,6 +10,7 @@ import ( "github.com/ory/fosite" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/downstreamsession" "go.pinniped.dev/internal/federationdomain/endpoints/loginurl" "go.pinniped.dev/internal/federationdomain/federationdomainproviders" @@ -36,6 +37,16 @@ func NewPostHandler( return httperr.Wrap(http.StatusUnprocessableEntity, "error finding upstream provider", err) } + auditLogger.Audit(auditevent.UsingUpstreamIDP, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + "displayName", idp.GetDisplayName(), + "resourceName", idp.GetProvider().GetResourceName(), + "resourceUID", idp.GetProvider().GetResourceUID(), + "type", idp.GetSessionProviderType(), + }, + }) + // Get the original params that were used at the authorization endpoint. downstreamAuthParams, err := url.ParseQuery(decodedState.AuthParams) if err != nil { @@ -66,6 +77,10 @@ func NewPostHandler( // Treat blank username or password as a bad username/password combination, as opposed to an internal error. if submittedUsername == "" || submittedPassword == "" { + auditLogger.Audit(auditevent.IncorrectUsernameOrPassword, &plog.AuditParams{ + ReqCtx: r.Context(), + }) + // User forgot to enter one of the required fields. // The user may try to log in again if they'd like, so redirect back to the login page with an error. return redirectToLoginPage(r, w, issuerURL, encodedState, loginurl.ShowBadUserPassErr) @@ -80,6 +95,10 @@ func NewPostHandler( // The user may try to log in again if they'd like, so redirect back to the login page with an error. return redirectToLoginPage(r, w, issuerURL, encodedState, loginurl.ShowInternalError) case err == resolvedldap.ErrAccessDeniedDueToUsernamePasswordNotAccepted: + auditLogger.Audit(auditevent.IncorrectUsernameOrPassword, &plog.AuditParams{ + ReqCtx: r.Context(), + }) + // The upstream did not accept the username/password combination. // The user may try to log in again if they'd like, so redirect back to the login page with an error. return redirectToLoginPage(r, w, issuerURL, encodedState, loginurl.ShowBadUserPassErr) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 24e9e5a15..d26ecbac9 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -24,6 +24,7 @@ import ( "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" @@ -290,6 +291,10 @@ func TestPostLoginEndpoint(t *testing.T) { prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) + noAuditLogsWanted := func(_ string) []testutil.WantedAuditLog { + return nil + } + tests := []struct { name string idps *testidplister.UpstreamIDPListerBuilder @@ -355,6 +360,12 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-ldap-idp", "upstreamIDPType": "ldap", @@ -407,6 +418,12 @@ func TestPostLoginEndpoint(t *testing.T) { ), wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-ldap-idp", "upstreamIDPType": "ldap", @@ -478,6 +495,12 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-active-directory-idp", + "resourceName": "some-active-directory-idp", + "resourceUID": "active-directory-resource-uid", + "type": "activedirectory", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "some-active-directory-idp", "upstreamIDPType": "activedirectory", @@ -786,6 +809,29 @@ func TestPostLoginEndpoint(t *testing.T) { "error_description": "The resource owner or authorization server denied the request. Reason: configured identity policy rejected this authentication: users who belong to certain upstream group are not allowed.", "state": happyDownstreamState, }), + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPType": "ldap", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "personalInfo": map[string]any{ + "upstreamUsername": "some-mapped-ldap-username", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, + }), + testutil.WantAuditLog("Authentication Rejected By Transforms", map[string]any{ + "reason": "configured identity policy rejected this authentication: users who belong to certain upstream group are not allowed", + }), + } + }, }, { name: "happy LDAP when downstream OIDC validations are skipped because the openid scope was not requested", @@ -854,6 +900,17 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), + } + }, }, { name: "bad password LDAP login", @@ -864,6 +921,17 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), + } + }, }, { name: "blank username LDAP login", @@ -874,6 +942,17 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), + } + }, }, { name: "blank password LDAP login", @@ -884,6 +963,17 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), + } + }, }, { name: "username and password sent as URI query params should be ignored since they are expected in form post body", @@ -904,6 +994,16 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: internalErrParamValue, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + } + }, }, { name: "downstream redirect uri does not match what is configured for client", @@ -915,6 +1015,30 @@ func TestPostLoginEndpoint(t *testing.T) { }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ + "upstreamIDPDisplayName": "some-ldap-idp", + "upstreamIDPType": "ldap", + "upstreamIDPResourceName": "some-ldap-idp", + "upstreamIDPResourceUID": "ldap-resource-uid", + "personalInfo": map[string]any{ + "upstreamUsername": "some-mapped-ldap-username", + "upstreamGroups": []any{"group1", "group2", "group3"}, + }, + }), + testutil.WantAuditLog("Session Started", map[string]any{ + "sessionID": sessionID, + "warnings": []any{}, // json: [] + "personalInfo": map[string]any{ + "username": "some-mapped-ldap-username", + "groups": []any{"group1", "group2", "group3"}, + "subject": "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=some-ldap-idp&sub=some-ldap-uid", + "additionalClaims": map[string]any{}, // json: {} + }, + }), + } + }, }, { name: "downstream redirect uri does not match what is configured for client with dynamic client", @@ -1116,8 +1240,9 @@ func TestPostLoginEndpoint(t *testing.T) { map[string]string{"scope": "openid offline_access pinniped:request-audience scope_not_allowed"}, ).Encode() }), - formParams: happyUsernamePasswordFormParams, - wantErr: "error using state downstream auth params", + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + wantAuditLogs: noAuditLogsWanted, }, { name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway", @@ -1217,6 +1342,7 @@ func TestPostLoginEndpoint(t *testing.T) { if tt.reqURIQuery != nil { req.URL.RawQuery = tt.reqURIQuery.Encode() } + req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) rsp := httptest.NewRecorder() @@ -1306,7 +1432,9 @@ func TestPostLoginEndpoint(t *testing.T) { } if test.wantAuditLogs != nil { - testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), actualAuditLog.String()) + wantAuditLogs := test.wantAuditLogs(sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "some-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } }) } From 611de03e013a2f47f198fe7dd66b60c517646647 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 13 Nov 2024 13:36:25 -0600 Subject: [PATCH 36/71] Add audit event 'Incorrect Username Or Password' to auth_handler and audit event 'Using Upstream IDP' to callback_handler --- .../endpoints/auth/auth_handler.go | 10 +++- .../endpoints/auth/auth_handler_test.go | 55 +++++++++++++++++++ .../endpoints/callback/callback_handler.go | 10 +++- .../callback/callback_handler_test.go | 30 ++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 7baa259e9..e2454f559 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -24,6 +24,7 @@ import ( "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/resolvedprovider" + "go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/responseutil" "go.pinniped.dev/internal/httputil/securityheader" @@ -261,12 +262,15 @@ func (h *authorizeHandler) authorizeWithoutBrowser( identity, loginExtras, err := idp.Login(r.Context(), submittedUsername, submittedPassword) if err != nil { + if err == resolvedldap.ErrAccessDeniedDueToUsernamePasswordNotAccepted { + h.auditLogger.Audit(auditevent.IncorrectUsernameOrPassword, &plog.AuditParams{ + ReqCtx: r.Context(), + }) + } + return err } - // TODO: Perhaps add audit event "Incorrect Username Or Password"? - // See "post_login_handler" for example - session, err := downstreamsession.NewPinnipedSession(r.Context(), h.auditLogger, &downstreamsession.SessionConfig{ UpstreamIdentity: identity, UpstreamLoginExtras: loginExtras, diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 865392de4..985666210 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -2178,6 +2178,33 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedErrorQuery), wantBodyString: "", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-password-granting-oidc-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, + }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-password-granting-oidc-idp", + "resourceName": "some-password-granting-oidc-idp", + "resourceUID": "some-password-granting-resource-uid", + "type": "oidc", + }), + } + }, }, { name: "wrong upstream password for LDAP authentication", @@ -2190,6 +2217,34 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithBadUsernamePasswordHintErrorQuery), wantBodyString: "", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "some-ldap-idp", + "redirect_uri": "http://127.0.0.1/callback", + "response_type": "code", + "scope": "openid profile email username groups", + "state": "redacted", + }, + }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "some-ldap-idp", + "resourceName": "some-ldap-idp", + "resourceUID": "ldap-resource-uid", + "type": "ldap", + }), + testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), + } + }, }, { name: "wrong upstream password for Active Directory authentication", diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index f8a84b35a..022d1569e 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -45,7 +45,15 @@ func NewHandler( return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found") } - // TODO: Add audit event "auditevent.UsingUpstreamIDP" + auditLogger.Audit(auditevent.UsingUpstreamIDP, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{ + "displayName", idp.GetDisplayName(), + "resourceName", idp.GetProvider().GetResourceName(), + "resourceUID", idp.GetProvider().GetResourceUID(), + "type", idp.GetSessionProviderType(), + }, + }) downstreamAuthParams, err := url.ParseQuery(decodedState.AuthParams) if err != nil { diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 861a43465..30c59c068 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -285,6 +285,12 @@ func TestCallbackEndpoint(t *testing.T) { testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "upstream-oidc-idp-name", + "resourceName": "upstream-oidc-idp-name", + "resourceUID": "upstream-oidc-resource-uid", + "type": "oidc", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "upstream-oidc-idp-name", "upstreamIDPType": "oidc", @@ -343,6 +349,12 @@ func TestCallbackEndpoint(t *testing.T) { testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "upstream-github-idp-name", + "resourceName": "upstream-github-idp-name", + "resourceUID": "upstream-github-idp-resource-uid", + "type": "github", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "upstream-github-idp-name", "upstreamIDPType": "github", @@ -718,6 +730,12 @@ func TestCallbackEndpoint(t *testing.T) { testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "upstream-oidc-idp-name", + "resourceName": "upstream-oidc-idp-name", + "resourceUID": "upstream-oidc-resource-uid", + "type": "oidc", + }), } }, }, @@ -1791,6 +1809,12 @@ func TestCallbackEndpoint(t *testing.T) { testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "upstream-oidc-idp-name", + "resourceName": "upstream-oidc-idp-name", + "resourceUID": "upstream-oidc-resource-uid", + "type": "oidc", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "upstream-oidc-idp-name", "upstreamIDPType": "oidc", @@ -1826,6 +1850,12 @@ func TestCallbackEndpoint(t *testing.T) { testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), + testutil.WantAuditLog("Using Upstream IDP", map[string]any{ + "displayName": "upstream-github-idp-name", + "resourceName": "upstream-github-idp-name", + "resourceUID": "upstream-github-idp-resource-uid", + "type": "github", + }), testutil.WantAuditLog("Identity From Upstream IDP", map[string]any{ "upstreamIDPDisplayName": "upstream-github-idp-name", "upstreamIDPType": "github", From de7781b7f9b476fec751931347da4c5a7fb2c848 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 13 Nov 2024 14:42:10 -0600 Subject: [PATCH 37/71] Use correct caller when generating audit events --- internal/plog/plog.go | 2 +- internal/plog/plog_test.go | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 1f612c70e..1bdbfb763 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -182,7 +182,7 @@ func (a *auditLogger) Audit(msg auditevent.Message, p *AuditParams) { func (p pLogger) audit(msg string, keysAndValues ...any) { // Always print log message (klogLevelWarning cannot be suppressed by configuration), // and always use the Info function because audit logs are not warnings or errors. - p.logr().V(klogLevelWarning).WithCallDepth(p.depth+1).Info(msg, keysAndValues...) + p.logr().V(klogLevelWarning).WithCallDepth(p.depth+2).Info(msg, keysAndValues...) } // Error logs show in the pod log output as `"level":"error","message":"some error msg"` diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index b87daf34f..8af8cb3af 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -41,8 +41,8 @@ func TestAudit(t *testing.T) { a.Audit("fake event type 2", &AuditParams{}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type 1","auditEvent":true} - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type 2","auditEvent":true} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func1","message":"fake event type 1","auditEvent":true} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func1","message":"fake event type 2","auditEvent":true} `), }, { @@ -51,7 +51,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{ReqCtx: context.Background()}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func2","message":"fake event type","auditEvent":true} `), }, { @@ -60,7 +60,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{ReqCtx: fakeReqContext}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func3","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id"} `), }, { @@ -69,7 +69,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{Session: &fakeSessionGetter{}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"sessionID":"fake-session-id"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func4","message":"fake event type","auditEvent":true,"sessionID":"fake-session-id"} `), }, { @@ -91,7 +91,7 @@ func TestAudit(t *testing.T) { }}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"username":"ryan","groups":["g1","g2"],"int":42,"float":42.75,"specialJSONChars\"👋\\":"hi\"👋\\","map":{"k1":1,"k2":2},"empty_list":[],"empty_map":{},"nil_list":[],"nil_map":{},"nil_ptr":null,"nil":null}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func5","message":"fake event type","auditEvent":true,"personalInfo":{"username":"ryan","groups":["g1","g2"],"int":42,"float":42.75,"specialJSONChars\"👋\\":"hi\"👋\\","map":{"k1":1,"k2":2},"empty_list":[],"empty_map":{},"nil_list":[],"nil_map":{},"nil_ptr":null,"nil":null}} `), }, { @@ -114,7 +114,7 @@ func TestAudit(t *testing.T) { }}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"username":"redacted","groups":["redacted 2 values"],"int":"redacted","float":"redacted","specialJSONChars\"👋\\":"redacted","map":{"redacted":"redacted 2 keys"},"empty_list":["redacted 0 values"],"empty_map":{"redacted":"redacted 0 keys"},"nil_list":["redacted 0 values"],"nil_map":{"redacted":"redacted 0 keys"},"nil_ptr":"redacted","nil":"redacted"}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func6","message":"fake event type","auditEvent":true,"personalInfo":{"username":"redacted","groups":["redacted 2 values"],"int":"redacted","float":"redacted","specialJSONChars\"👋\\":"redacted","map":{"redacted":"redacted 2 keys"},"empty_list":["redacted 0 values"],"empty_map":{"redacted":"redacted 0 keys"},"nil_list":["redacted 0 values"],"nil_map":{"redacted":"redacted 0 keys"},"nil_ptr":"redacted","nil":"redacted"}} `), }, { @@ -123,7 +123,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"foo"}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func7","message":"fake event type","auditEvent":true} `), }, { @@ -132,7 +132,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{"foo", 42, "bar"}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"foo":42}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func8","message":"fake event type","auditEvent":true,"personalInfo":{"foo":42}} `), }, { @@ -141,7 +141,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{PIIKeysAndValues: []any{42, "foo", "bar", "baz"}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"personalInfo":{"cannotCastKeyNameToString":"foo","bar":"baz"}} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func9","message":"fake event type","auditEvent":true,"personalInfo":{"cannotCastKeyNameToString":"foo","bar":"baz"}} `), }, { @@ -150,7 +150,7 @@ func TestAudit(t *testing.T) { a.Audit("fake event type", &AuditParams{KeysAndValues: []any{"foo", 42, "bar", "baz"}}) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"foo":42,"bar":"baz"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func10","message":"fake event type","auditEvent":true,"foo":42,"bar":"baz"} `), }, { @@ -164,7 +164,7 @@ func TestAudit(t *testing.T) { }) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"ryan","groups":["g1","g2"],"bat":14},"foo":42,"bar":"baz"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func11","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"ryan","groups":["g1","g2"],"bat":14},"foo":42,"bar":"baz"} `), }, { @@ -179,7 +179,7 @@ func TestAudit(t *testing.T) { }) }, want: here.Doc(` - {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).Audit","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"redacted","groups":["redacted 2 values"],"bat":"redacted"},"foo":42,"bar":"baz"} + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog_test.go:$plog.TestAudit.func12","message":"fake event type","auditEvent":true,"auditID":"fake-audit-id","sessionID":"fake-session-id","personalInfo":{"username":"redacted","groups":["redacted 2 values"],"bat":"redacted"},"foo":42,"bar":"baz"} `), }, } From eab3fde3afef52b1a0bb1cb87c6678eae5f37a82 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 13 Nov 2024 11:46:50 -0800 Subject: [PATCH 38/71] introduce common method to audit HTTP request parameters --- .../endpoints/auth/auth_handler.go | 32 +--- .../endpoints/auth/auth_handler_test.go | 168 +++++++++--------- .../endpoints/callback/callback_handler.go | 18 +- .../callback/callback_handler_test.go | 72 +++++++- internal/plog/plog.go | 33 ++++ 5 files changed, 207 insertions(+), 116 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index e2454f559..a06ad4a91 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -5,7 +5,6 @@ package auth import ( - "errors" "fmt" "net/http" "net/url" @@ -104,8 +103,9 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { hadPasswordHeader := len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 requestedBrowserlessFlow := hadUsernameHeader || hadPasswordHeader - // Need to parse the request params, so we can get the IDP name and audit log the params. - if err := parseForm(r); err != nil { + // Audit the request params. Also gives us access to the IDP name param for use below, + // before fosite would normally parse the params. + if err := h.auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil { oidc.WriteAuthorizeError(r, w, h.oauthHelperWithoutStorage, fosite.NewAuthorizeRequest(), err, requestedBrowserlessFlow) return @@ -121,11 +121,6 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }, }) - h.auditLogger.Audit(auditevent.HTTPRequestParameters, &plog.AuditParams{ - ReqCtx: r.Context(), - KeysAndValues: auditevent.SanitizeParams(r.Form, paramsSafeToLog()), - }) - if r.Method != http.MethodPost && r.Method != http.MethodGet { // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest // Authorization Servers MUST support the use of the HTTP GET and POST methods defined in @@ -176,27 +171,6 @@ func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.authorize(w, r, requestedBrowserlessFlow, idp) } -// parseForm parses the query params and/or POST body form params. It returns an error, or in the case of success it -// has the side effect of leaving the parsed form params on the http.Request in the Form field. Request body -// parameters take precedence over URL query string values. -func parseForm(r *http.Request) error { - // The style of form parsing and the text of the error is inspired by fosite's implementation of NewAuthorizeRequest(). - // Fosite only calls ParseMultipartForm() there. However, although ParseMultipartForm() calls ParseForm(), - // it swallows errors from ParseForm() sometimes. To avoid having any errors swallowed, we call both. - // When fosite calls ParseMultipartForm() later, it will be a noop. - if err := r.ParseForm(); err != nil { - return fosite.ErrInvalidRequest. - WithHint("Unable to parse form params, make sure to send a properly formatted query params or form request body."). - WithWrap(err).WithDebug(err.Error()) - } - if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - return fosite.ErrInvalidRequest. - WithHint("Unable to parse multipart HTTP body, make sure to send a properly formatted form request body."). - WithWrap(err).WithDebug(err.Error()) - } - return nil -} - func (h *authorizeHandler) authorize( w http.ResponseWriter, r *http.Request, diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 985666210..c3af4ffe0 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -726,10 +726,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -743,6 +739,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", @@ -774,10 +774,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": dynamicClientID, @@ -791,6 +787,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", @@ -821,10 +821,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -838,6 +834,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", @@ -869,10 +869,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": dynamicClientID, @@ -886,6 +882,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-github-idp", "resourceName": "some-github-idp", @@ -916,10 +916,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -933,6 +929,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", @@ -964,10 +964,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -980,6 +976,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", @@ -1012,10 +1012,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1028,6 +1024,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), } }, }, @@ -1051,10 +1051,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyStringWithLocationInHref: true, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1068,6 +1064,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", @@ -1175,10 +1175,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1192,6 +1188,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", @@ -1264,10 +1264,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1281,6 +1277,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", @@ -1387,10 +1387,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1404,6 +1400,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", @@ -1461,10 +1461,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo ), wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1478,6 +1474,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", @@ -1780,10 +1780,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1797,6 +1793,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-active-directory-idp", "resourceName": "some-active-directory-idp", @@ -1894,10 +1894,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(_ stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -1912,6 +1908,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-oidc-idp", "resourceName": "some-oidc-idp", @@ -2180,10 +2180,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -2197,6 +2193,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", @@ -2219,10 +2219,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": true, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -2236,6 +2232,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": true, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-ldap-idp", "resourceName": "some-ldap-idp", @@ -2295,10 +2295,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": true, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -2312,6 +2308,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": true, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-password-granting-oidc-idp", "resourceName": "some-password-granting-oidc-idp", @@ -4031,16 +4031,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "Method Not Allowed: PUT (try GET or POST)\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "baz", "foo": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), } }, }, @@ -4054,16 +4054,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "Method Not Allowed: PATCH (try GET or POST)\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "baz", "foo": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), } }, }, @@ -4077,16 +4077,16 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo wantBodyString: "Method Not Allowed: DELETE (try GET or POST)\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "baz", "foo": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), } }, }, @@ -4328,10 +4328,6 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo ) test.wantAuditLogs = func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ - testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ - "Pinniped-Username": false, - "Pinniped-Password": false, - }), testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "client_id": "pinniped-cli", @@ -4345,6 +4341,10 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo "state": "redacted", }, }), + testutil.WantAuditLog("HTTP Request Custom Headers Used", map[string]any{ + "Pinniped-Username": false, + "Pinniped-Password": false, + }), testutil.WantAuditLog("Using Upstream IDP", map[string]any{ "displayName": "some-other-new-idp-display-name", "resourceName": "some-other-new-idp-name", diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 022d1569e..4cd330505 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -9,6 +9,7 @@ import ( "net/url" "github.com/ory/fosite" + "k8s.io/apimachinery/pkg/util/sets" "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/downstreamsession" @@ -21,6 +22,15 @@ import ( "go.pinniped.dev/internal/plog" ) +func paramsSafeToLog() sets.Set[string] { + return sets.New[string]( + // Due to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, + // authorize errors can have these parameters, which should not contain PII or secrets and are safe to log. + "error", "error_description", "error_uri", + // Note that this endpoint also receives 'code' and 'state' params, which are not safe to log. + ) +} + func NewHandler( upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersFinderI, oauthHelper fosite.OAuth2Provider, @@ -29,6 +39,11 @@ func NewHandler( auditLogger plog.AuditLogger, ) http.Handler { handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + if err := auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil { + plog.DebugErr("error parsing callback request params", err) + return httperr.New(http.StatusBadRequest, "error parsing request params") + } + encodedState, decodedState, err := validateRequest(r, stateDecoder, cookieDecoder) if err != nil { return err @@ -137,7 +152,8 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) if authcode(r) == "" { plog.Info("code param not found") - return "", nil, httperr.New(http.StatusBadRequest, "code param not found") + return "", nil, httperr.New(http.StatusBadRequest, + "code param not found: check URL in browser's address bar for error parameters from upstream identity provider") } return encodedState, decodedState, nil diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 30c59c068..bb54d270d 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -282,6 +282,9 @@ func TestCallbackEndpoint(t *testing.T) { }, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -346,6 +349,9 @@ func TestCallbackEndpoint(t *testing.T) { }, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -727,6 +733,9 @@ func TestCallbackEndpoint(t *testing.T) { }, wantAuditLogs: func(encodedStateParam stateparam.Encoded, _ string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -1122,6 +1131,13 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusMethodNotAllowed, wantContentType: htmlContentType, wantBody: "Method Not Allowed: PUT (try GET)\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), + } + }, }, { name: "POST method is invalid", @@ -1150,6 +1166,41 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Method Not Allowed: DELETE (try GET)\n", }, + { + name: "params cannot be parsed", + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), + method: http.MethodGet, + path: newRequestPath().String() + "&invalid;;param", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error parsing request params\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{} + }, + }, + { + name: "error redirect from upstream IDP audit logs the error params from the OAuth2 spec", + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyOIDCState).WithoutCode().String() + "&error=some_error&error_description=some_description&error_uri=some_uri", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: code param not found: check URL in browser's address bar for error parameters from upstream identity provider\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "state": "redacted", + "error": "some_error", + "error_description": "some_description", + "error_uri": "some_uri", + }, + }), + } + }, + }, { name: "code param was not included on request", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), @@ -1158,7 +1209,14 @@ func TestCallbackEndpoint(t *testing.T) { csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, - wantBody: "Bad Request: code param not found\n", + wantBody: "Bad Request: code param not found: check URL in browser's address bar for error parameters from upstream identity provider\n", + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), + } + }, }, { name: "state param was not included on request", @@ -1170,7 +1228,11 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Bad Request: state param not found\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{} + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted"}, + }), + } }, }, { @@ -1806,6 +1868,9 @@ func TestCallbackEndpoint(t *testing.T) { }, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -1847,6 +1912,9 @@ func TestCallbackEndpoint(t *testing.T) { }, wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"code": "redacted", "state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), diff --git a/internal/plog/plog.go b/internal/plog/plog.go index 1bdbfb763..c97e450fa 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -30,12 +30,16 @@ package plog import ( "context" "encoding/json" + "errors" "fmt" + "net/http" "os" "reflect" "slices" "github.com/go-logr/logr" + "github.com/ory/fosite" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/audit" "go.pinniped.dev/internal/auditevent" @@ -72,6 +76,7 @@ type AuditParams struct { // that would make unit testing of audit logs harder. type AuditLogger interface { Audit(msg auditevent.Message, p *AuditParams) + AuditRequestParams(r *http.Request, reqParamsSafeToLog sets.Set[string]) error } // Logger implements the plog logging convention described above. The global functions in this package @@ -178,6 +183,34 @@ func (a *auditLogger) Audit(msg auditevent.Message, p *AuditParams) { a.logger.audit(string(msg), allKV...) } +// AuditRequestParams parses the URL's query params and/or POST body form params, and then audit logs them with +// redaction as specified by reqParamsSafeToLog. It can return an error, or in the case of success it has the side +// effect of leaving the parsed form params on the http.Request in the Form field. Note that request body parameters +// take precedence over URL query values. +func (a *auditLogger) AuditRequestParams(r *http.Request, reqParamsSafeToLog sets.Set[string]) error { + // The style of form parsing and the text of the error is inspired by fosite's implementation of NewAuthorizeRequest(). + // Fosite only calls ParseMultipartForm() there. However, although ParseMultipartForm() calls ParseForm(), + // it swallows errors from ParseForm() sometimes. To avoid having any errors swallowed, we call both. + // When fosite calls ParseMultipartForm() later, it will be a noop. + if err := r.ParseForm(); err != nil { + return fosite.ErrInvalidRequest. + WithHint("Unable to parse form params, make sure to send a properly formatted query params or form request body."). + WithWrap(err).WithDebug(err.Error()) + } + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + return fosite.ErrInvalidRequest. + WithHint("Unable to parse multipart HTTP body, make sure to send a properly formatted form request body."). + WithWrap(err).WithDebug(err.Error()) + } + + a.Audit(auditevent.HTTPRequestParameters, &AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: auditevent.SanitizeParams(r.Form, reqParamsSafeToLog), + }) + + return nil +} + // audit is used internally by AuditLogger to print an audit log event to the pLogger's output. func (p pLogger) audit(msg string, keysAndValues ...any) { // Always print log message (klogLevelWarning cannot be suppressed by configuration), From c06141c871c8f6886e14efc8586374d076be3e8f Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 13 Nov 2024 11:56:00 -0800 Subject: [PATCH 39/71] token handler uses common method to audit HTTP request parameters --- .../endpoints/auth/auth_handler_test.go | 4 +- .../callback/callback_handler_test.go | 2 +- .../login/post_login_handler_test.go | 2 +- .../endpoints/token/token_handler.go | 17 ++++++ .../endpoints/token/token_handler_test.go | 7 +-- .../tokenendpointauditor/parameter_auditor.go | 61 ------------------- .../endpointsmanager/manager.go | 2 - internal/federationdomain/oidc/oidc.go | 4 -- 8 files changed, 24 insertions(+), 75 deletions(-) delete mode 100644 internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index c3af4ffe0..12bffeda9 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -272,14 +272,14 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo // Inject this into our test subject at the last second so we get a fresh storage for every test. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. kubeOauthStore := storage.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) - return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil), kubeOauthStore + return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore } createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *storage.NullStorage) { // Configure fosite the same way that the production code would, using NullStorage to turn off storage. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. nullOauthStore := storage.NewNullStorage(secretsClient, oidcClientsClient, bcrypt.MinCost) - return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil), nullOauthStore + return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore } upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index bb54d270d..6000ccb43 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -1961,7 +1961,7 @@ func TestCallbackEndpoint(t *testing.T) { hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil) + oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) auditLogger, actualAuditLog := plog.TestAuditLogger(t) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index d26ecbac9..d64c7f396 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -1335,7 +1335,7 @@ func TestPostLoginEndpoint(t *testing.T) { hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() - oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration, nil) + oauthHelper := oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) req := httptest.NewRequest(http.MethodPost, "/ignored", strings.NewReader(tt.formParams.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index b52531190..590dcef10 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -31,6 +31,18 @@ import ( "go.pinniped.dev/internal/psession" ) +func paramsSafeToLog() sets.Set[string] { + return sets.New( + // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. + // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. + "grant_type", "client_id", "redirect_uri", "scope", + // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. + // Redact subject_token and actor_token. + // We don't allow all of these, but they should be safe to log. + "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", + ) +} + func NewHandler( idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI, oauthHelper fosite.OAuth2Provider, @@ -39,6 +51,11 @@ func NewHandler( auditLogger plog.AuditLogger, ) http.Handler { return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + if err := auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil { + oauthHelper.WriteAccessError(r.Context(), w, nil, err) + return nil + } + session := psession.NewPinnipedSession() accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session) if err != nil { diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 9fdcfa31c..228b4175e 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -128,7 +128,7 @@ var ( fositeInvalidPayloadErrorBody = here.Doc(` { "error": "invalid_request", - "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Unable to parse HTTP body, make sure to send a properly formatted form request body." + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Unable to parse form params, make sure to send a properly formatted query params or form request body." } `) @@ -5117,7 +5117,7 @@ func exchangeAuthcodeForTokens( var oauthHelper fosite.OAuth2Provider // Note that makeHappyOauthHelper() calls simulateAuthEndpointHavingAlreadyRun() to preload the session storage. - oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession, auditLogger) + oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.makeJwksSigningKeyAndProvider, test.customSessionData, test.modifySession) subject = NewHandler( idps, @@ -5336,12 +5336,11 @@ func makeHappyOauthHelper( makeJwksSigningKeyAndProvider MakeJwksSigningKeyAndProviderFunc, initialCustomSessionData *psession.CustomSessionData, modifySession func(session *psession.PinnipedSession), - auditLogger plog.AuditLogger, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() jwtSigningKey, jwkProvider := makeJwksSigningKeyAndProvider(t, goodIssuer) - oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration(), auditLogger) + oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration()) authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper, initialCustomSessionData, modifySession) return oauthHelper, authResponder.GetCode(), jwtSigningKey } diff --git a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go b/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go deleted file mode 100644 index e4765a17d..000000000 --- a/internal/federationdomain/endpoints/tokenendpointauditor/parameter_auditor.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package tokenendpointauditor - -import ( - "context" - - "github.com/ory/fosite" - "github.com/ory/fosite/compose" - "k8s.io/apimachinery/pkg/util/sets" - - "go.pinniped.dev/internal/auditevent" - "go.pinniped.dev/internal/plog" -) - -type parameterAuditorHandler struct { - auditLogger plog.AuditLogger -} - -func AuditorHandlerFactory(auditLogger plog.AuditLogger) compose.Factory { - return func(_ fosite.Configurator, _ any, _ any) any { - return ¶meterAuditorHandler{ - auditLogger: auditLogger, - } - } -} - -var _ fosite.TokenEndpointHandler = (*parameterAuditorHandler)(nil) - -func (p parameterAuditorHandler) PopulateTokenEndpointResponse(_ context.Context, _ fosite.AccessRequester, _ fosite.AccessResponder) error { - return nil -} - -func (p parameterAuditorHandler) HandleTokenEndpointRequest(_ context.Context, _ fosite.AccessRequester) error { - return nil -} - -func (p parameterAuditorHandler) CanSkipClientAuth(_ context.Context, _ fosite.AccessRequester) bool { - return false -} - -func paramsSafeToLogTokenEndpoint() sets.Set[string] { - return sets.New( - // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. - // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. - "grant_type", "client_id", "redirect_uri", "scope", - // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. - // Redact subject_token and actor_token. - // We don't allow all of these, but they should be safe to log. - "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", - ) -} - -func (p parameterAuditorHandler) CanHandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) bool { - p.auditLogger.Audit(auditevent.HTTPRequestParameters, &plog.AuditParams{ - ReqCtx: ctx, - KeysAndValues: auditevent.SanitizeParams(requester.GetRequestForm(), paramsSafeToLogTokenEndpoint()), - }) - return false -} diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index 03141895a..d270203af 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -119,7 +119,6 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro tokenHMACKeyGetter, nil, timeoutsConfiguration, - m.auditLogger, ) // For all the other endpoints, make another oauth helper with exactly the same settings except use real storage. @@ -129,7 +128,6 @@ func (m *Manager) SetFederationDomains(federationDomains ...*federationdomainpro tokenHMACKeyGetter, m.dynamicJWKSProvider, timeoutsConfiguration, - m.auditLogger, ) upstreamStateEncoder := dynamiccodec.New( diff --git a/internal/federationdomain/oidc/oidc.go b/internal/federationdomain/oidc/oidc.go index 77a9b3a28..db187acfc 100644 --- a/internal/federationdomain/oidc/oidc.go +++ b/internal/federationdomain/oidc/oidc.go @@ -22,7 +22,6 @@ import ( "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" - "go.pinniped.dev/internal/federationdomain/endpoints/tokenendpointauditor" "go.pinniped.dev/internal/federationdomain/endpoints/tokenexchange" "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/federationdomain/idtokenlifespan" @@ -232,7 +231,6 @@ func FositeOauth2Helper( hmacSecretOfLengthAtLeast32Func func() []byte, jwksProvider jwks.DynamicJWKSProvider, timeoutsConfiguration timeouts.Configuration, - auditLogger plog.AuditLogger, ) fosite.OAuth2Provider { oauthConfig := &fosite.Config{ IDTokenIssuer: issuer, @@ -273,8 +271,6 @@ func FositeOauth2Helper( CoreStrategy: strategy.NewDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func), OpenIDConnectTokenStrategy: strategy.NewDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), }, - // Put this before others to make sure it logs params! - tokenendpointauditor.AuditorHandlerFactory(auditLogger), compose.OAuth2AuthorizeExplicitFactory, compose.OAuth2RefreshTokenGrantFactory, // Use a custom factory to allow selective overrides of the ID token lifespan during authcode exchange. From 51d1cc7a967824a6fff82a4947a596a64e87dd9a Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 13 Nov 2024 12:50:17 -0800 Subject: [PATCH 40/71] refactor and add unit test for AuditRequestParams() --- internal/auditevent/audit_event.go | 44 --- internal/auditevent/audit_event_test.go | 171 ----------- internal/auditid/auditid.go | 38 +++ .../endpoints/auth/auth_handler_test.go | 4 +- .../callback/callback_handler_test.go | 4 +- .../endpoints/login/login_handler_test.go | 4 +- .../login/post_login_handler_test.go | 4 +- .../endpointsmanager/manager.go | 3 +- .../requestlogger/request_logger.go | 29 -- internal/plog/plog.go | 41 ++- internal/plog/plog_test.go | 284 ++++++++++++++++++ 11 files changed, 372 insertions(+), 254 deletions(-) delete mode 100644 internal/auditevent/audit_event_test.go create mode 100644 internal/auditid/auditid.go diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index b03f82cca..3077f5263 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -3,12 +3,6 @@ package auditevent -import ( - "net/url" - - "k8s.io/apimachinery/pkg/util/sets" -) - type Message string const ( @@ -32,41 +26,3 @@ const ( TokenCredentialRequestUnsupportedUserInfo Message = "TokenCredentialRequest Unsupported UserInfo" //nolint:gosec // this is not a credential IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" //nolint:gosec // this is not a credential ) - -// SanitizeParams can be used to redact all params not included in the allowedKeys set. -// Useful when audit logging HTTPRequestParameters events. -func SanitizeParams(inputParams url.Values, allowedKeys sets.Set[string]) []any { - params := make(map[string]string) - multiValueParams := make(url.Values) - - transform := func(key, value string) string { - if !allowedKeys.Has(key) { - return "redacted" - } - - unescape, err := url.QueryUnescape(value) - if err != nil { - // ignore these errors and just use the original query parameter - unescape = value - } - return unescape - } - - for key := range inputParams { - for i, p := range inputParams[key] { - transformed := transform(key, p) - if i == 0 { - params[key] = transformed - } - - if len(inputParams[key]) > 1 { - multiValueParams[key] = append(multiValueParams[key], transformed) - } - } - } - - if len(multiValueParams) > 0 { - return []any{"params", params, "multiValueParams", multiValueParams} - } - return []any{"params", params} -} diff --git a/internal/auditevent/audit_event_test.go b/internal/auditevent/audit_event_test.go deleted file mode 100644 index 51924710c..000000000 --- a/internal/auditevent/audit_event_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright 2024 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package auditevent - -import ( - "net/url" - "testing" - - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/util/sets" -) - -func TestSanitizeParams(t *testing.T) { - tests := []struct { - name string - params url.Values - allowedKeys sets.Set[string] - want []any - }{ - { - name: "nil values", - params: nil, - allowedKeys: nil, - want: []any{ - "params", - map[string]string{}, - }, - }, - { - name: "empty values", - params: url.Values{}, - allowedKeys: nil, - want: []any{ - "params", - map[string]string{}, - }, - }, - { - name: "all allowed values", - params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, - allowedKeys: sets.New("foo", "bar"), - want: []any{ - "params", - map[string]string{ - "bar": "d", - "foo": "a", - }, - "multiValueParams", - url.Values{ - "bar": []string{"d", "e", "f"}, - "foo": []string{"a", "b", "c"}, - }, - }, - }, - { - name: "all allowed values with single values", - params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, - allowedKeys: sets.New("foo", "bar"), - want: []any{ - "params", - map[string]string{ - "foo": "a", - "bar": "d", - }, - }, - }, - { - name: "some allowed values", - params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, - allowedKeys: sets.New("foo"), - want: []any{ - "params", - map[string]string{ - "bar": "redacted", - "foo": "a", - }, - "multiValueParams", - url.Values{ - "bar": []string{"redacted", "redacted", "redacted"}, - "foo": []string{"a", "b", "c"}, - }, - }, - }, - { - name: "some allowed values with single values", - params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, - allowedKeys: sets.New("foo"), - want: []any{ - "params", - map[string]string{ - "bar": "redacted", - "foo": "a", - }, - }, - }, - { - name: "no allowed values", - params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, - allowedKeys: sets.New[string](), - want: []any{ - "params", - map[string]string{ - "bar": "redacted", - "foo": "redacted", - }, - "multiValueParams", - url.Values{ - "bar": {"redacted", "redacted", "redacted"}, - "foo": {"redacted", "redacted", "redacted"}, - }, - }, - }, - { - name: "nil allowed values", - params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, - allowedKeys: nil, - want: []any{ - "params", - map[string]string{ - "bar": "redacted", - "foo": "redacted", - }, - "multiValueParams", - url.Values{ - "bar": {"redacted", "redacted", "redacted"}, - "foo": {"redacted", "redacted", "redacted"}, - }, - }, - }, - { - name: "url decodes allowed values", - params: url.Values{ - "foo": []string{"a%3Ab", "c", "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"}, - "bar": []string{"d", "e", "f"}, - }, - allowedKeys: sets.New("foo"), - want: []any{ - "params", - map[string]string{ - "bar": "redacted", - "foo": "a:b", - }, - "multiValueParams", - url.Values{ - "bar": {"redacted", "redacted", "redacted"}, - "foo": {"a:b", "c", "urn:ietf:params:oauth:grant-type:token-exchange"}, - }, - }, - }, - { - name: "ignores url decode errors", - params: url.Values{ - "bad_encoding": []string{"%.."}, - }, - allowedKeys: sets.New("bad_encoding"), - want: []any{ - "params", - map[string]string{ - "bad_encoding": "%..", - }, - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // This comparison should require the exact order - require.Equal(t, test.want, SanitizeParams(test.params, test.allowedKeys)) - }) - } -} diff --git a/internal/auditid/auditid.go b/internal/auditid/auditid.go new file mode 100644 index 000000000..4c9605df7 --- /dev/null +++ b/internal/auditid/auditid.go @@ -0,0 +1,38 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auditid + +import ( + "net/http" + + "github.com/google/uuid" + "k8s.io/apimachinery/pkg/types" + apiserveraudit "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/audit" +) + +// NewRequestWithAuditID is public for use in unit tests. Production code should use WithAuditID(). +func NewRequestWithAuditID(r *http.Request, newAuditIDFunc func() string) (*http.Request, string) { + ctx := audit.WithAuditContext(r.Context()) + r = r.WithContext(ctx) + + auditID := newAuditIDFunc() + audit.WithAuditID(ctx, types.UID(auditID)) + + return r, auditID +} + +func WithAuditID(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add a randomly generated request ID to the context for this request. + r, auditID := NewRequestWithAuditID(r, func() string { + return uuid.New().String() + }) + + // Send the Audit-ID response header. + w.Header().Set(apiserveraudit.HeaderAuditID, auditID) + + handler.ServeHTTP(w, r) + }) +} diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 12bffeda9..8b8350d61 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -30,12 +30,12 @@ import ( supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" - "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" @@ -4118,7 +4118,7 @@ func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo if test.customPasswordHeader != nil { req.Header.Set("Pinniped-Password", *test.customPasswordHeader) } - req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() subject.ServeHTTP(rsp, req) diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 6000ccb43..d20a157c4 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -22,10 +22,10 @@ import ( supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" - "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/federationdomain/upstreamprovider" @@ -1979,7 +1979,7 @@ func TestCallbackEndpoint(t *testing.T) { if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } - req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 3182e096f..e3a463a76 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -13,8 +13,8 @@ import ( "github.com/gorilla/securecookie" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/federationdomain/oidc" - "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/plog" @@ -412,7 +412,7 @@ func TestLoginEndpoint(t *testing.T) { if test.csrfCookie != "" { req.Header.Set("Cookie", test.csrfCookie) } - req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-audit-id" }) rsp := httptest.NewRecorder() testGetHandler := func( diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index d64c7f396..06cdc73f7 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -19,12 +19,12 @@ import ( supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/celtransformer" "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" - "go.pinniped.dev/internal/federationdomain/requestlogger" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" @@ -1342,7 +1342,7 @@ func TestPostLoginEndpoint(t *testing.T) { if tt.reqURIQuery != nil { req.URL.RawQuery = tt.reqURIQuery.Encode() } - req, _ = requestlogger.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) rsp := httptest.NewRecorder() diff --git a/internal/federationdomain/endpointsmanager/manager.go b/internal/federationdomain/endpointsmanager/manager.go index d270203af..a1dc50558 100644 --- a/internal/federationdomain/endpointsmanager/manager.go +++ b/internal/federationdomain/endpointsmanager/manager.go @@ -11,6 +11,7 @@ import ( corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/federationdomain/csrftoken" "go.pinniped.dev/internal/federationdomain/dynamiccodec" @@ -197,7 +198,7 @@ func (m *Manager) buildHandlerChain(nextHandler http.Handler, auditInternalPaths // Log all requests, including audit ID. handler = requestlogger.WithHTTPRequestAuditLogging(handler, m.auditLogger, auditInternalPathsCfg) // Add random audit ID to request context and response headers. - handler = requestlogger.WithAuditID(handler) + handler = auditid.WithAuditID(handler) m.handlerChain = handler } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index b73a34cd4..1560379a9 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -11,10 +11,6 @@ import ( "slices" "time" - "github.com/google/uuid" - "k8s.io/apimachinery/pkg/types" - apisaudit "k8s.io/apiserver/pkg/apis/audit" - "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/endpoints/responsewriter" "k8s.io/utils/clock" @@ -24,31 +20,6 @@ import ( "go.pinniped.dev/internal/plog" ) -// NewRequestWithAuditID is public for use in unit tests. Production code should use WithAuditID(). -func NewRequestWithAuditID(r *http.Request, newAuditIDFunc func() string) (*http.Request, string) { - ctx := audit.WithAuditContext(r.Context()) - r = r.WithContext(ctx) - - auditID := newAuditIDFunc() - audit.WithAuditID(ctx, types.UID(auditID)) - - return r, auditID -} - -func WithAuditID(handler http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Add a randomly generated request ID to the context for this request. - r, auditID := NewRequestWithAuditID(r, func() string { - return uuid.New().String() - }) - - // Send the Audit-ID response header. - w.Header().Set(apisaudit.HeaderAuditID, auditID) - - handler.ServeHTTP(w, r) - }) -} - func WithHTTPRequestAuditLogging(handler http.Handler, auditLogger plog.AuditLogger, auditInternalPathsCfg supervisor.AuditInternalPaths) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { rl := newRequestLogger(req, w, auditLogger, time.Now(), auditInternalPathsCfg) diff --git a/internal/plog/plog.go b/internal/plog/plog.go index c97e450fa..061b5fe9b 100644 --- a/internal/plog/plog.go +++ b/internal/plog/plog.go @@ -33,6 +33,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "os" "reflect" "slices" @@ -205,7 +206,7 @@ func (a *auditLogger) AuditRequestParams(r *http.Request, reqParamsSafeToLog set a.Audit(auditevent.HTTPRequestParameters, &AuditParams{ ReqCtx: r.Context(), - KeysAndValues: auditevent.SanitizeParams(r.Form, reqParamsSafeToLog), + KeysAndValues: sanitizeRequestParams(r.Form, reqParamsSafeToLog), }) return nil @@ -539,3 +540,41 @@ func (p *piiKeysAndValues) asJSONValue(v any) string { } } } + +// sanitizeRequestParams can be used to redact all params not included in the allowedKeys set. +// Useful when audit logging HTTPRequestParameters events. +func sanitizeRequestParams(inputParams url.Values, allowedKeys sets.Set[string]) []any { + params := make(map[string]string) + multiValueParams := make(url.Values) + + transform := func(key, value string) string { + if !allowedKeys.Has(key) { + return "redacted" + } + + unescape, err := url.QueryUnescape(value) + if err != nil { + // ignore these errors and just use the original query parameter + unescape = value + } + return unescape + } + + for key := range inputParams { + for i, p := range inputParams[key] { + transformed := transform(key, p) + if i == 0 { + params[key] = transformed + } + + if len(inputParams[key]) > 1 { + multiValueParams[key] = append(multiValueParams[key], transformed) + } + } + } + + if len(multiValueParams) > 0 { + return []any{"params", params, "multiValueParams", multiValueParams} + } + return []any{"params", params} +} diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index 8af8cb3af..e087e531c 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -6,15 +6,21 @@ package plog import ( "context" "fmt" + "net/http" + "net/http/httptest" + "net/url" "runtime" "strings" "testing" "time" "github.com/coreos/go-semver/semver" + "github.com/ory/fosite" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/audit" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/here" ) @@ -196,6 +202,125 @@ func TestAudit(t *testing.T) { } } +func TestAuditRequestParams(t *testing.T) { + tests := []struct { + name string + req func() *http.Request + paramsSafeToLog sets.Set[string] + want string + wantErr *fosite.RFC6749Error + }{ + { + name: "get request", + req: func() *http.Request { + params := url.Values{ + "foo": []string{"bar1", "bar2"}, + "baz": []string{"baz1", "baz2"}, + } + req := httptest.NewRequestWithContext(context.Background(), "GET", "/?"+params.Encode(), nil) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + return req + }, + paramsSafeToLog: sets.New("foo"), + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).AuditRequestParams","message":"HTTP Request Parameters","auditEvent":true,"auditID":"some-audit-id","params":{"baz":"redacted","foo":"bar1"},"multiValueParams":{"baz":["redacted","redacted"],"foo":["bar1","bar2"]}} + `), + }, + { + name: "post request with urlencoded form in body", + req: func() *http.Request { + params := url.Values{ + "foo": []string{"bar1", "bar2"}, + "baz": []string{"baz1", "baz2"}, + } + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader(params.Encode())) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + paramsSafeToLog: sets.New("foo"), + want: here.Doc(` + {"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","caller":"plog/plog.go:$plog.(*auditLogger).AuditRequestParams","message":"HTTP Request Parameters","auditEvent":true,"auditID":"some-audit-id","params":{"baz":"redacted","foo":"bar1"},"multiValueParams":{"baz":["redacted","redacted"],"foo":["bar1","bar2"]}} + `), + }, + { + name: "get request with bad form", + req: func() *http.Request { + req := httptest.NewRequestWithContext(context.Background(), "GET", "/?invalid;;;form", nil) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + return req + }, + paramsSafeToLog: sets.New("foo"), + wantErr: &fosite.RFC6749Error{ + CodeField: fosite.ErrInvalidRequest.CodeField, + ErrorField: fosite.ErrInvalidRequest.ErrorField, + DescriptionField: fosite.ErrInvalidRequest.DescriptionField, + HintField: "Unable to parse form params, make sure to send a properly formatted query params or form request body.", + DebugField: "invalid semicolon separator in query", + }, + }, + { + name: "post request with bad urlencoded form in body", + req: func() *http.Request { + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader("invalid;;;form")) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req + }, + paramsSafeToLog: sets.New("foo"), + wantErr: &fosite.RFC6749Error{ + CodeField: fosite.ErrInvalidRequest.CodeField, + ErrorField: fosite.ErrInvalidRequest.ErrorField, + DescriptionField: fosite.ErrInvalidRequest.DescriptionField, + HintField: "Unable to parse form params, make sure to send a properly formatted query params or form request body.", + DebugField: "invalid semicolon separator in query", + }, + }, + { + name: "post request with bad multipart form in body", + req: func() *http.Request { + req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader("this is not a valid multipart form")) + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + req.Header.Set("Content-Type", "multipart/form-data") + return req + }, + paramsSafeToLog: sets.New("foo"), + wantErr: &fosite.RFC6749Error{ + CodeField: fosite.ErrInvalidRequest.CodeField, + ErrorField: fosite.ErrInvalidRequest.ErrorField, + DescriptionField: fosite.ErrInvalidRequest.DescriptionField, + HintField: "Unable to parse multipart HTTP body, make sure to send a properly formatted form request body.", + DebugField: "no multipart boundary param in Content-Type", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + l, actualAuditLogs := TestAuditLogger(t) + + rawErr := l.AuditRequestParams(test.req(), test.paramsSafeToLog) + + if test.wantErr == nil { + require.NoError(t, rawErr) + } else { + require.Error(t, rawErr) + err, ok := rawErr.(*fosite.RFC6749Error) + require.True(t, ok) + require.Equal(t, test.wantErr.CodeField, err.CodeField) + require.Equal(t, test.wantErr.ErrorField, err.ErrorField) + require.Equal(t, test.wantErr.DescriptionField, err.DescriptionField) + require.Equal(t, test.wantErr.HintField, err.HintField) + require.Equal(t, test.wantErr.DebugField, err.DebugField) + } + + require.Equal(t, strings.TrimSpace(test.want), strings.TrimSpace(actualAuditLogs.String())) + }) + } +} + func TestPlog(t *testing.T) { runtimeVersion := runtime.Version() if strings.HasPrefix(runtimeVersion, "go") { @@ -565,3 +690,162 @@ func testAllPlogMethods(l Logger) { l.All("all", "panda", 2) l.Always("always", "panda", 2) } + +func TestSanitizeRequestParams(t *testing.T) { + tests := []struct { + name string + params url.Values + allowedKeys sets.Set[string] + want []any + }{ + { + name: "nil values", + params: nil, + allowedKeys: nil, + want: []any{ + "params", + map[string]string{}, + }, + }, + { + name: "empty values", + params: url.Values{}, + allowedKeys: nil, + want: []any{ + "params", + map[string]string{}, + }, + }, + { + name: "all allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New("foo", "bar"), + want: []any{ + "params", + map[string]string{ + "bar": "d", + "foo": "a", + }, + "multiValueParams", + url.Values{ + "bar": []string{"d", "e", "f"}, + "foo": []string{"a", "b", "c"}, + }, + }, + }, + { + name: "all allowed values with single values", + params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, + allowedKeys: sets.New("foo", "bar"), + want: []any{ + "params", + map[string]string{ + "foo": "a", + "bar": "d", + }, + }, + }, + { + name: "some allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New("foo"), + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a", + }, + "multiValueParams", + url.Values{ + "bar": []string{"redacted", "redacted", "redacted"}, + "foo": []string{"a", "b", "c"}, + }, + }, + }, + { + name: "some allowed values with single values", + params: url.Values{"foo": []string{"a"}, "bar": []string{"d"}}, + allowedKeys: sets.New("foo"), + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a", + }, + }, + }, + { + name: "no allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: sets.New[string](), + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "redacted", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"redacted", "redacted", "redacted"}, + }, + }, + }, + { + name: "nil allowed values", + params: url.Values{"foo": []string{"a", "b", "c"}, "bar": []string{"d", "e", "f"}}, + allowedKeys: nil, + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "redacted", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"redacted", "redacted", "redacted"}, + }, + }, + }, + { + name: "url decodes allowed values", + params: url.Values{ + "foo": []string{"a%3Ab", "c", "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"}, + "bar": []string{"d", "e", "f"}, + }, + allowedKeys: sets.New("foo"), + want: []any{ + "params", + map[string]string{ + "bar": "redacted", + "foo": "a:b", + }, + "multiValueParams", + url.Values{ + "bar": {"redacted", "redacted", "redacted"}, + "foo": {"a:b", "c", "urn:ietf:params:oauth:grant-type:token-exchange"}, + }, + }, + }, + { + name: "ignores url decode errors", + params: url.Values{ + "bad_encoding": []string{"%.."}, + }, + allowedKeys: sets.New("bad_encoding"), + want: []any{ + "params", + map[string]string{ + "bad_encoding": "%..", + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // This comparison should require the exact order + require.Equal(t, test.want, sanitizeRequestParams(test.params, test.allowedKeys)) + }) + } +} From b54365c1999a395abccb1c104c17d400e2cf55df Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 13 Nov 2024 13:34:45 -0800 Subject: [PATCH 41/71] audit log request params on GET and POST login handlers --- .../endpoints/login/login_handler.go | 14 +++++ .../endpoints/login/login_handler_test.go | 57 ++++++++++++++++++- internal/plog/plog_test.go | 10 ++-- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/internal/federationdomain/endpoints/login/login_handler.go b/internal/federationdomain/endpoints/login/login_handler.go index 288694db1..04e077c38 100644 --- a/internal/federationdomain/endpoints/login/login_handler.go +++ b/internal/federationdomain/endpoints/login/login_handler.go @@ -6,6 +6,8 @@ package login import ( "net/http" + "k8s.io/apimachinery/pkg/util/sets" + idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" @@ -25,6 +27,13 @@ type HandlerFunc func( decodedState *oidc.UpstreamStateParamData, ) error +func paramsSafeToLog() sets.Set[string] { + return sets.New[string]( + // This param is sometimes added by the POST login handler when redirecting back to the GET login handler. + "err", + ) +} + // NewHandler returns a http.Handler that serves the login endpoint for IDPs that don't have their own web UI for login. // // This handler takes care of the shared concerns between the GET and POST methods of the login endpoint: @@ -43,6 +52,11 @@ func NewHandler( auditLogger plog.AuditLogger, ) http.Handler { loginHandler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + if err := auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil { + plog.DebugErr("error parsing callback request params", err) + return httperr.New(http.StatusBadRequest, "error parsing request params") + } + var handler HandlerFunc switch r.Method { case http.MethodGet: diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index e3a463a76..9b7fd877a 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "go.pinniped.dev/internal/auditid" + "go.pinniped.dev/internal/federationdomain/endpoints/loginurl" "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/httputil/httperr" @@ -134,7 +135,11 @@ func TestLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Method Not Allowed: PUT (try GET or POST)\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{} + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), + } }, }, { @@ -200,7 +205,11 @@ func TestLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Bad Request: state param not found\n", wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{} + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{}, + }), + } }, }, { @@ -295,6 +304,17 @@ func TestLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Bad Request: not a supported upstream IDP type for this endpoint: \"oidc\"\n", }, + { + name: "GET request with invalid form", + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithUpstreamIDPType("oidc").Build(t, happyStateCodec), + ).String() + "&invalid;;param", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error parsing request params\n", + }, { name: "POST request when upstream IDP type in state param is not supported by this endpoint", method: http.MethodPost, @@ -330,6 +350,27 @@ func TestLoginEndpoint(t *testing.T) { wantEncodedState: happyState, wantDecodedState: expectedHappyDecodedUpstreamStateParam(), }, + { + name: "happy GET request with err param which can be set by the real POST handler on redirects back to the GET handler", + method: http.MethodGet, + path: happyPathWithState + "&" + loginurl.ErrParamName + "=" + string(loginurl.ShowBadUserPassErr), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusOK, + wantContentType: htmlContentType, + wantBody: happyGetResult, + wantEncodedState: happyState, + wantDecodedState: expectedHappyDecodedUpstreamStateParam(), + wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted", "err": "login_error"}, + }), + testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ + "authorizeID": encodedStateParam.AuthorizeID(), + }), + } + }, + }, { name: "happy GET request for LDAP upstream", method: http.MethodGet, @@ -342,6 +383,9 @@ func TestLoginEndpoint(t *testing.T) { wantDecodedState: expectedHappyDecodedUpstreamStateParam(), wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -360,6 +404,9 @@ func TestLoginEndpoint(t *testing.T) { wantDecodedState: expectedHappyDecodedUpstreamStateParam(), wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -378,6 +425,9 @@ func TestLoginEndpoint(t *testing.T) { wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), @@ -396,6 +446,9 @@ func TestLoginEndpoint(t *testing.T) { wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(), wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{"state": "redacted"}, + }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), }), diff --git a/internal/plog/plog_test.go b/internal/plog/plog_test.go index e087e531c..bb296ea95 100644 --- a/internal/plog/plog_test.go +++ b/internal/plog/plog_test.go @@ -218,7 +218,6 @@ func TestAuditRequestParams(t *testing.T) { "baz": []string{"baz1", "baz2"}, } req := httptest.NewRequestWithContext(context.Background(), "GET", "/?"+params.Encode(), nil) - req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) return req }, paramsSafeToLog: sets.New("foo"), @@ -234,7 +233,6 @@ func TestAuditRequestParams(t *testing.T) { "baz": []string{"baz1", "baz2"}, } req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader(params.Encode())) - req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req }, @@ -247,7 +245,6 @@ func TestAuditRequestParams(t *testing.T) { name: "get request with bad form", req: func() *http.Request { req := httptest.NewRequestWithContext(context.Background(), "GET", "/?invalid;;;form", nil) - req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) return req }, paramsSafeToLog: sets.New("foo"), @@ -263,7 +260,6 @@ func TestAuditRequestParams(t *testing.T) { name: "post request with bad urlencoded form in body", req: func() *http.Request { req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader("invalid;;;form")) - req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req }, @@ -280,7 +276,6 @@ func TestAuditRequestParams(t *testing.T) { name: "post request with bad multipart form in body", req: func() *http.Request { req := httptest.NewRequestWithContext(context.Background(), "POST", "/", strings.NewReader("this is not a valid multipart form")) - req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) req.Header.Set("Content-Type", "multipart/form-data") return req }, @@ -301,7 +296,10 @@ func TestAuditRequestParams(t *testing.T) { l, actualAuditLogs := TestAuditLogger(t) - rawErr := l.AuditRequestParams(test.req(), test.paramsSafeToLog) + req := test.req() + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "some-audit-id" }) + + rawErr := l.AuditRequestParams(req, test.paramsSafeToLog) if test.wantErr == nil { require.NoError(t, rawErr) From c16ebe1707137f80374e6daf5e95c27461500f8a Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 13 Nov 2024 13:45:44 -0800 Subject: [PATCH 42/71] add unit test for audit logging when token refresh updates groups --- .../endpoints/token/token_handler_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 228b4175e..1eeb148e1 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -2961,6 +2961,41 @@ func TestRefreshGrant(t *testing.T) { {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, }, + wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "client_id": "pinniped-cli", + "grant_type": "refresh_token", + "refresh_token": "redacted", + "scope": "openid", + }, + }), + testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ + "sessionID": sessionID, + "personalInfo": map[string]any{ + "upstreamGroups": []any{ + "new-group1", + "new-group2", + "new-group3", + }, + "upstreamUsername": "some-username", + }, + }), + testutil.WantAuditLog("Session Refreshed", map[string]any{ + "sessionID": sessionID, + "personalInfo": map[string]any{ + "username": "some-username", + "groups": []any{ + "new-group1", + "new-group2", + "new-group3", + }, + "subject": "https://issuer?sub=some-subject", + }, + }), + } + }, }, }, }, From f38851314526a22b96bfe9da9853151b59dcf47e Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 14 Nov 2024 10:59:41 -0600 Subject: [PATCH 43/71] resolve TODO by adding docs --- .../endpoints/auth/auth_handler.go | 18 +++++++++++++++++- internal/testutil/log_lines.go | 12 ++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index a06ad4a91..03b6b481f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -213,7 +213,23 @@ func (h *authorizeHandler) authorize( } } if err != nil { - // TODO: Consider an audit event here + // No specific audit event is emitted here in the case of an authorization error. + // There are currently seven possible cases: + // (1) OIDC with cli_password: + // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. + // - There's no way to determine why the OIDC provider rejected the request. + // (2) OIDC with browser_authcode: this endpoint only redirects upstream + // (3) LDAP with cli_password: + // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. + // - If we know that the LDAP provider rejected the request due to incorrect username or password, + // Pinniped will provide the "Incorrect Username Or Password" audit event. + // (4) LDAP with browser_authcode: this endpoint only redirects to the /login page + // (5) Active Directory with cli_password: + // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. + // - If we know that the Active Directory provider rejected the request due to incorrect username or password, + // Pinniped will provide the "Incorrect Username Or Password" audit event. + // (6) Active Directory with browser_authcode: this endpoint only redirects to the /login page + // (7) GitHub with browser_authcode (cli_password is not supported): this endpoint only redirects upstream oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow) } } diff --git a/internal/testutil/log_lines.go b/internal/testutil/log_lines.go index da776f201..73b28cca4 100644 --- a/internal/testutil/log_lines.go +++ b/internal/testutil/log_lines.go @@ -45,14 +45,14 @@ func WantAuditIDOnEveryAuditLog(wantedAuditLogs []WantedAuditLog, wantAuditID st } func GetStateParam(t *testing.T, fullURL string) stateparam.Encoded { - var encodedStateParam stateparam.Encoded - if fullURL != "" { - path, err := url.Parse(fullURL) - require.NoError(t, err) - encodedStateParam = stateparam.Encoded(path.Query().Get("state")) + if fullURL == "" { + var empty stateparam.Encoded + return empty } - return encodedStateParam + path, err := url.Parse(fullURL) + require.NoError(t, err) + return stateparam.Encoded(path.Query().Get("state")) } func CompareAuditLogs(t *testing.T, wantAuditLogs []WantedAuditLog, actualAuditLogsOneLiner string) { From c2018717b6799bb816a1dee58b600441bd360fcc Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 09:55:31 -0800 Subject: [PATCH 44/71] audit log OIDCClientSecretRequests --- internal/auditevent/audit_event.go | 1 + internal/registry/clientsecretrequest/rest.go | 62 ++++++++++++--- .../registry/clientsecretrequest/rest_test.go | 79 ++++++++++++++++++- internal/supervisor/apiserver/apiserver.go | 2 + internal/supervisor/server/server.go | 3 + 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index 3077f5263..aaba2aee6 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -20,6 +20,7 @@ const ( UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential SessionGarbageCollected Message = "Session Garbage Collected" UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" + OIDCClientSecretRequestUpdatedSecrets Message = "OIDCClientSecretRequest Updated Secrets" TokenCredentialRequestAuthenticatedUser Message = "TokenCredentialRequest Authenticated User" //nolint:gosec // this is not a credential TokenCredentialRequestAuthenticationFailed Message = "TokenCredentialRequest Authentication Failed" //nolint:gosec // this is not a credential TokenCredentialRequestUnexpectedError Message = "TokenCredentialRequest Unexpected Error" //nolint:gosec // this is not a credential diff --git a/internal/registry/clientsecretrequest/rest.go b/internal/registry/clientsecretrequest/rest.go index 4ac86fb59..333a7b4d3 100644 --- a/internal/registry/clientsecretrequest/rest.go +++ b/internal/registry/clientsecretrequest/rest.go @@ -28,8 +28,11 @@ import ( "k8s.io/utils/trace" clientsecretapi "go.pinniped.dev/generated/latest/apis/supervisor/clientsecret" + supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" configv1alpha1clientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/oidcclientsecretstorage" + "go.pinniped.dev/internal/plog" ) // Cost is a good bcrypt cost for 2022, should take about 250 ms to validate. @@ -48,6 +51,7 @@ func NewREST( randByteGenerator io.Reader, byteHasher byteHasher, timeNowFunc timeNowFunc, + auditLogger plog.AuditLogger, ) *REST { return &REST{ secretStorage: oidcclientsecretstorage.New(secretsClient), @@ -58,6 +62,7 @@ func NewREST( byteHasher: byteHasher, tableConvertor: rest.NewDefaultTableConvertor(resource), timeNowFunc: timeNowFunc, + auditLogger: auditLogger, } } @@ -70,6 +75,7 @@ type REST struct { byteHasher byteHasher tableConvertor rest.TableConvertor timeNowFunc timeNowFunc + auditLogger plog.AuditLogger } // Assert that our *REST implements all the optional interfaces that we expect it to implement. @@ -121,7 +127,12 @@ func (*REST) GetSingularName() string { return "oidcclientsecretrequest" } -func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { +func (r *REST) Create( + ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + options *metav1.CreateOptions, +) (runtime.Object, error) { t := trace.FromContext(ctx).Nest("create", trace.Field{Key: "kind", Value: "OIDCClientSecretRequest"}, trace.Field{Key: "metadata.name", Value: name(obj)}, @@ -137,14 +148,9 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation t.Step("validateRequest") // Find the specified OIDCClient. - oidcClient, err := r.oidcClientsClient.Get(ctx, req.Name, metav1.GetOptions{}) + oidcClient, err := r.findClient(ctx, req.Name, t) if err != nil { - traceFailureWithError(t, "oidcClientsClient.Get", err) - if apierrors.IsNotFound(err) { - errs := field.ErrorList{field.NotFound(field.NewPath("metadata", "name"), req.Name)} - return nil, apierrors.NewInvalid(kindFromContext(ctx), req.Name, errs) - } - return nil, apierrors.NewInternalError(fmt.Errorf("getting client %q failed", req.Name)) + return nil, err } t.Step("oidcClientsClient.Get") @@ -155,19 +161,20 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation traceFailureWithError(t, "secretStorage.Get", err) return nil, apierrors.NewInternalError(fmt.Errorf("getting secret for client %q failed", req.Name)) } + numPreviouslyStoredHashes := len(hashes) t.Step("secretStorage.Get") // If requested, generate a new client secret and add it to the list. - var secret string + var generatedSecret string if req.Spec.GenerateNewSecret { - secret, err = generateSecret(r.randByteGenerator) + generatedSecret, err = generateSecret(r.randByteGenerator) if err != nil { traceFailureWithError(t, "generateSecret", err) return nil, apierrors.NewInternalError(fmt.Errorf("client secret generation failed")) } t.Step("generateSecret") - hash, err := r.byteHasher([]byte(secret), r.cost) + hash, err := r.byteHasher([]byte(generatedSecret), r.cost) if err != nil { traceFailureWithError(t, "bcrypt.GenerateFromPassword", err) return nil, apierrors.NewInternalError(fmt.Errorf("hash generation failed")) @@ -179,8 +186,16 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation // If requested, remove all client secrets except for the most recent one. needsRevoke := req.Spec.RevokeOldSecrets && len(hashes) > 0 + numRevokedHashes := 0 if needsRevoke { hashes = []string{hashes[0]} + if generatedSecret == "" { + // There is no newly generated secret, so one old hash is retained and all others are revoked. + numRevokedHashes = numPreviouslyStoredHashes - 1 + } else { + // The newly generated secret was added to the list, and all old hashes are revoked. + numRevokedHashes = numPreviouslyStoredHashes + } } // If anything was requested to change... @@ -204,6 +219,16 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return nil, apierrors.NewInternalError(fmt.Errorf("setting client secret failed")) } t.Step("secretStorage.Set") + + r.auditLogger.Audit(auditevent.OIDCClientSecretRequestUpdatedSecrets, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "clientID", req.Name, + "generatedSecret", len(generatedSecret) > 0, + "revokedSecrets", numRevokedHashes, + "totalSecrets", len(hashes), + }, + }) } // Return the new secret in plaintext, if one was generated, along with the total number of secrets. @@ -218,12 +243,25 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation RevokeOldSecrets: req.Spec.RevokeOldSecrets, }, Status: clientsecretapi.OIDCClientSecretRequestStatus{ - GeneratedSecret: secret, + GeneratedSecret: generatedSecret, TotalClientSecrets: len(hashes), }, }, nil } +func (r *REST) findClient(ctx context.Context, clientName string, tracer *trace.Trace) (*supervisorconfigv1alpha1.OIDCClient, error) { + oidcClient, err := r.oidcClientsClient.Get(ctx, clientName, metav1.GetOptions{}) + if err != nil { + traceFailureWithError(tracer, "oidcClientsClient.Get", err) + if apierrors.IsNotFound(err) { + errs := field.ErrorList{field.NotFound(field.NewPath("metadata", "name"), clientName)} + return nil, apierrors.NewInvalid(kindFromContext(ctx), clientName, errs) + } + return nil, apierrors.NewInternalError(fmt.Errorf("getting client %q failed", clientName)) + } + return oidcClient, nil +} + func (r *REST) validateRequest( ctx context.Context, obj runtime.Object, diff --git a/internal/registry/clientsecretrequest/rest_test.go b/internal/registry/clientsecretrequest/rest_test.go index 365f97d75..bf9b05607 100644 --- a/internal/registry/clientsecretrequest/rest_test.go +++ b/internal/registry/clientsecretrequest/rest_test.go @@ -21,6 +21,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/audit" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" kubefake "k8s.io/client-go/kubernetes/fake" @@ -31,6 +32,7 @@ import ( supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/oidcclientsecretstorage" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" ) @@ -44,6 +46,7 @@ func TestNew(t *testing.T) { nil, nil, nil, + nil, ) require.NotNil(t, r) @@ -120,6 +123,7 @@ func TestCreate(t *testing.T) { wantErrStatus *metav1.Status wantHashes *wantHashes wantLogStepSubstrings []string + wantAuditLog []testutil.WantedAuditLog }{ { name: "wrong type of request object provided", @@ -714,6 +718,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-happy-new-secret", + "generatedSecret": true, + "revokedSecrets": float64(0), + "totalSecrets": float64(1), + }), + }, }, { name: "happy path: secret exists, prepend new secret hash to secret to the list of hashes for found oidcclient", @@ -783,6 +796,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-append-new-secret-hash", + "generatedSecret": true, + "revokedSecrets": float64(0), + "totalSecrets": float64(3), + }), + }, }, { name: "happy path: secret exists, append new secret hash to secret and revoke old for found oidcclient", @@ -849,9 +871,18 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-append-new-secret-hash", + "generatedSecret": true, + "revokedSecrets": float64(2), + "totalSecrets": float64(1), + }), + }, }, { - name: "happy path: secret exists, revoke old secrets but retain latest for found oidcclient", + name: "happy path: secret exists, revoke oldest secrets but retain latest old secret for found oidcclient", args: args{ ctx: namespacedContext, obj: &clientsecretapi.OIDCClientSecretRequest{ @@ -874,6 +905,7 @@ func TestCreate(t *testing.T) { []string{ "hashed-password-1", "hashed-password-2", + "hashed-password-3", }, )) }, @@ -913,6 +945,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-some-client", + "generatedSecret": false, + "revokedSecrets": float64(2), + "totalSecrets": float64(1), + }), + }, }, { name: "secret exists but oidcclient secret has too many hashes, fails to create when RevokeOldSecrets:false (max 5), secret is not updated", @@ -1413,6 +1454,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-some-client", + "generatedSecret": true, + "revokedSecrets": float64(1), + "totalSecrets": float64(1), + }), + }, }, { name: "happy path: generate new secret when existing secrets is max (5)", @@ -1482,6 +1532,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-some-client", + "generatedSecret": true, + "revokedSecrets": float64(5), + "totalSecrets": float64(1), + }), + }, }, { name: "happy path: generate new secret when existing secrets exceeds maximum (5)", @@ -1552,6 +1611,15 @@ func TestCreate(t *testing.T) { `secretStorage.Set`, `END`, }, + wantAuditLog: []testutil.WantedAuditLog{ + testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ + "auditID": "fake-audit-id", + "clientID": "client.oauth.pinniped.dev-some-client", + "generatedSecret": true, + "revokedSecrets": float64(6), + "totalSecrets": float64(1), + }), + }, }, } for _, tt := range tests { @@ -1606,6 +1674,10 @@ func TestCreate(t *testing.T) { fakeByteGenerator = strings.NewReader(fakeRandomBytes + "these extra bytes should be ignored since we only read 32 bytes") } + auditLogger, actualAuditLog := plog.TestAuditLogger(t) + ctx := audit.WithAuditContext(tt.args.ctx) + audit.WithAuditID(ctx, "fake-audit-id") + r := NewREST( schema.GroupResource{Group: "bears", Resource: "panda"}, secretsClient, @@ -1615,9 +1687,10 @@ func TestCreate(t *testing.T) { fakeByteGenerator, fakeHasher, fakeTimeNowFunc, + auditLogger, ) - got, err := r.Create(tt.args.ctx, tt.args.obj, tt.args.createValidation, tt.args.options) + got, err := r.Create(ctx, tt.args.obj, tt.args.createValidation, tt.args.options) require.Equal(t, tt.want, got) if tt.wantErrStatus != nil { @@ -1646,6 +1719,8 @@ func TestCreate(t *testing.T) { } requireExactlyOneLogLineWithMultipleSteps(t, logger, tt.wantLogStepSubstrings) + + testutil.CompareAuditLogs(t, tt.wantAuditLog, actualAuditLog.String()) }) } } diff --git a/internal/supervisor/apiserver/apiserver.go b/internal/supervisor/apiserver/apiserver.go index 2d078897a..b698d4cae 100644 --- a/internal/supervisor/apiserver/apiserver.go +++ b/internal/supervisor/apiserver/apiserver.go @@ -39,6 +39,7 @@ type ExtraConfig struct { Secrets corev1client.SecretInterface OIDCClients configv1alpha1clientset.OIDCClientInterface Namespace string + AuditLogger plog.AuditLogger } type PinnipedServer struct { @@ -92,6 +93,7 @@ func (c completedConfig) New() (*PinnipedServer, error) { rand.Reader, bcrypt.GenerateFromPassword, metav1.Now, + c.ExtraConfig.AuditLogger, ) return clientSecretReqGVR, clientSecretReqStorage }, diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index 8307df4db..a87160486 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -529,6 +529,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), client.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), serverInstallationNamespace, + auditLogger, ) if err != nil { return fmt.Errorf("could not configure aggregated API server: %w", err) @@ -639,6 +640,7 @@ func getAggregatedAPIServerConfig( secrets corev1client.SecretInterface, oidcClients v1alpha1.OIDCClientInterface, serverInstallationNamespace string, + auditLogger plog.AuditLogger, ) (*apiserver.Config, error) { codecs := serializer.NewCodecFactory(scheme) @@ -705,6 +707,7 @@ func getAggregatedAPIServerConfig( Secrets: secrets, OIDCClients: oidcClients, Namespace: serverInstallationNamespace, + AuditLogger: auditLogger, }, } return apiServerConfig, nil From a84b76e56a78d6ea213e0d80b74c03872bb571ee Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 12:08:34 -0800 Subject: [PATCH 45/71] audit log session ID in token handler for every grant type Co-authored-by: Joshua Casey --- internal/auditevent/audit_event.go | 1 + .../endpoints/token/token_handler.go | 6 +++ .../endpoints/token/token_handler_test.go | 42 ++++++++++++++----- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index aaba2aee6..363b06bea 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -16,6 +16,7 @@ const ( IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" SessionStarted Message = "Session Started" SessionRefreshed Message = "Session Refreshed" + SessionFound Message = "Session Found" AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential SessionGarbageCollected Message = "Session Garbage Collected" diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 590dcef10..c09925797 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -64,6 +64,12 @@ func NewHandler( return nil } + // Log sessionID for cross-request correlation purposes. + auditLogger.Audit(auditevent.SessionFound, &plog.AuditParams{ + ReqCtx: r.Context(), + Session: accessRequest, + }) + // Check if we are performing a refresh grant. if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) { // The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable. diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 1eeb148e1..9f0773fc3 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -44,6 +44,7 @@ import ( supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/internal/auditid" "go.pinniped.dev/internal/celtransformer" "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/federationdomain/clientregistry" @@ -397,6 +398,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -466,6 +468,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -557,6 +560,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -1195,6 +1199,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -1213,6 +1218,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -1793,6 +1799,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn req := httptest.NewRequest("POST", "/token/exchange/path/shouldn't/matter", body(request.Form).ReadCloser()) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-token-exchange-audit-id" }) rsp = httptest.NewRecorder() if test.modifyRequestHeaders != nil { @@ -1818,7 +1825,9 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") if test.wantAuditLogs != nil { - testutil.CompareAuditLogs(t, test.wantAuditLogs(sessionID), actualAuditLog.String()) + wantAuditLogs := test.wantAuditLogs(sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } var parsedResponseBody map[string]any @@ -2339,6 +2348,7 @@ func TestRefreshGrant(t *testing.T) { "scope": "openid", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ "sessionID": sessionID, "personalInfo": map[string]any{ @@ -2536,6 +2546,7 @@ func TestRefreshGrant(t *testing.T) { "scope": "openid", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ "sessionID": sessionID, "personalInfo": map[string]any{ @@ -2607,6 +2618,7 @@ func TestRefreshGrant(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), } }, }, @@ -2971,6 +2983,7 @@ func TestRefreshGrant(t *testing.T) { "scope": "openid", }, }), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("Identity Refreshed From Upstream IDP", map[string]any{ "sessionID": sessionID, "personalInfo": map[string]any{ @@ -4967,10 +4980,14 @@ func TestRefreshGrant(t *testing.T) { } reqContextWarningRecorder := &TestWarningRecorder{} - reqContext := warning.WithWarningRecorder(context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context"), reqContextWarningRecorder) req := httptest.NewRequest("POST", "/path/shouldn't/matter", - happyRefreshRequestBody(firstRefreshToken).ReadCloser()).WithContext(reqContext) + happyRefreshRequestBody(firstRefreshToken).ReadCloser()). + WithContext(warning.WithWarningRecorder( + context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context"), + reqContextWarningRecorder, + )) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-refresh-grant-audit-id" }) if test.refreshRequest.modifyTokenRequest != nil { test.refreshRequest.modifyTokenRequest(req, firstRefreshToken, parsedAuthcodeExchangeResponseBody["access_token"].(string)) } @@ -4983,31 +5000,33 @@ func TestRefreshGrant(t *testing.T) { t.Logf("second response body: %q", refreshResponse.Body.String()) if test.refreshRequest.want.wantAuditLogs != nil { - testutil.CompareAuditLogs(t, test.refreshRequest.want.wantAuditLogs(sessionID), actualAuditLog.String()) + wantAuditLogs := test.refreshRequest.want.wantAuditLogs(sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-refresh-grant-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } // Test that we did or did not make a call to the upstream provider's interface to perform refresh. switch { case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil: - test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args.Ctx = reqContext + test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToOIDCPerformRefresh(t, test.refreshRequest.want.wantOIDCUpstreamRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantOIDCUpstreamRefreshCall.args, ) case test.refreshRequest.want.wantLDAPUpstreamRefreshCall != nil: - test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args.Ctx = reqContext + test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToLDAPPerformRefresh(t, test.refreshRequest.want.wantLDAPUpstreamRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantLDAPUpstreamRefreshCall.args, ) case test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall != nil: - test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args.Ctx = reqContext + test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToActiveDirectoryPerformRefresh(t, test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantActiveDirectoryUpstreamRefreshCall.args, ) case test.refreshRequest.want.wantGithubUpstreamRefreshCall != nil: - test.refreshRequest.want.wantGithubUpstreamRefreshCall.args.Ctx = reqContext + test.refreshRequest.want.wantGithubUpstreamRefreshCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToGithubGetUser(t, test.refreshRequest.want.wantGithubUpstreamRefreshCall.performedByUpstreamName, test.refreshRequest.want.wantGithubUpstreamRefreshCall.args, @@ -5019,7 +5038,7 @@ func TestRefreshGrant(t *testing.T) { // Test that we did or did not make a call to the upstream OIDC provider interface to validate the // new ID token that was returned by the upstream refresh, in the case of an OIDC upstream. if test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall != nil { - test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args.Ctx = reqContext + test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args.Ctx = req.Context() test.idps.RequireExactlyOneCallToValidateToken(t, test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.performedByUpstreamName, test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args, @@ -5179,6 +5198,7 @@ func exchangeAuthcodeForTokens( if test.modifyTokenRequest != nil { test.modifyTokenRequest(req, authCode) } + req, _ = auditid.NewRequestWithAuditID(req, func() string { return "fake-code-grant-audit-id" }) rsp = httptest.NewRecorder() approxRequestTime := time.Now() @@ -5189,7 +5209,9 @@ func exchangeAuthcodeForTokens( sessionID = getSessionID(t, secrets) if test.want.wantAuditLogs != nil { - testutil.CompareAuditLogs(t, test.want.wantAuditLogs(sessionID), actualAuditLog.String()) + wantAuditLogs := test.want.wantAuditLogs(sessionID) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-code-grant-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) } wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) From 76bda127608546a5c1a2571c30b4a7f485e5ad0e Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 12:08:53 -0800 Subject: [PATCH 46/71] update audit-logging.md to resolve todos --- site/content/docs/reference/audit-logging.md | 334 ++++++++++++++++--- 1 file changed, 285 insertions(+), 49 deletions(-) diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index 23f43cc34..fe2ae070c 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -20,7 +20,7 @@ and audited by the [standard Kubernetes audit logging](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/) capabilities. -In addition, Pinniped exposes several APIs to all end-users to provide end-user authentication. +In addition, Pinniped exposes several APIs to all end users to provide end-user authentication. For these APIs, Pinniped offers additional audit logging capabilities. These additional audit logs appear in the pod logs of the Supervisor and Concierge pods. Each line of the pod logs is a JSON object. Although these audit events are interleaved with other pod log messages, they are identifiable by always @@ -30,16 +30,16 @@ having an `"auditEvent"=true` key/value pair. Both the Supervisor and the Concierge offer custom resource definitions (CRDs) for configuration, which are protected by Kubernetes RBAC and typically only available for administrators to use. -End-users typically cannot access these APIs, and they are not part of the authentication flows for end-users. +End users typically cannot access these APIs, and they are not part of the authentication flows for end users. Changes to these resources are audited by the standard Kubernetes audit logging. The Pinniped Supervisor offers one additional API for administrators, which is an aggregated API called `OIDCClientSecretRequest` to create client secrets for `OIDCClient` resources. -End-users typically cannot access this API (protected by Kubernetes RBAC), and it is not part of the authentication -flows for end-users. This API is audited by both the standard Kubernetes audit logging and may emit Pinniped audit events. +End users typically cannot access this API (protected by Kubernetes RBAC), and it is not part of the authentication +flows for end users. This API is audited by the standard Kubernetes audit logging and may also emit Pinniped audit events. The Pinniped Concierge offers two public APIs for end-user authentication, which are both aggregated APIs. -These will be audited by both the standard Kubernetes audit logging and may emit Pinniped audit events. +These will be audited by the standard Kubernetes audit logging and may also emit Pinniped audit events. - `TokenCredendtialRequest`: This API authenticates a user and returns a temporary cluster credential for that user. - `WhoAmIRequest`: This API returns the username and group memberships of the user who invokes it. @@ -82,14 +82,14 @@ audit logs both forwards and backwards in time. The values for these keys are op lines of audit events to be correlated when they came from a single HTTP request. This `auditID` is also returned to the client as an HTTP response header to allow for correlation between the request as observed by the client and the logs as observed by the administrator. For aggregated APIs only, the `auditID` can also be used to - correlate Pinniped audit events with Kubernetes audit logs, which will use the same `auditID`. + correlate Pinniped audit events with Kubernetes audit logs, which will use the same `auditID` value for a particular request. - When applicable, audit logs have a `sessionID` which is the unique ID of a stored Pinniped Supervisor user session, to allow audit events to be correlated which relate to a single session even when they are caused by different requests or controllers. The same `sessionID` can help you observe all the actions performed during a single user's session across multiple HTTP requests that make up a fresh login, token exchanges, multiple session refreshes, and session garbage collection. - When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated - across some of the browser redirects which relate to a single login attempt by an end-user. This is only applicable + across some of the browser redirects which relate to a single login attempt by an end user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. Each audit event may also have more key/value pairs specific to the event's type. @@ -101,25 +101,33 @@ Audit events are always enabled. There are two configuration options available: 1. By default, usernames and group names are not included in the audit events. This is because these names may include personally identifiable information (PII) which you may wish to avoid sending to your pod logs. However, authentication audit logs can be more useful when this information is included. -2. By default, some endpoints that are internal to the Kubernetes cluster are not audited in the pod logs. - These include, for example, a `healthz` endpoint that is used for pod liveness and readiness probes, - some discovery endpoints called by the Kubernetes API server to discover the endpoints made available by - the Pinniped pods, and other similar endpoints. These are typically not available to end-users and therefore - not always as interesting for authentication auditing. +2. By default, the Supervisor does not audit log requests made to the `healthz` endpoint, which is used for + pod liveness and readiness probes, because it is called so often and it has no behavior other than returning OK. Both of these can be optionally enabled in the ConfigMaps which hold the pod startup settings for the Supervisor and Concierge deployments. When these ConfigMaps are changed, the corresponding Supervisor or Concierge pods must -be restarted for the new settings to be picked up by the pods. - -TODO: Document this configuration, probably something like so: +be restarted for the new settings to be picked up by the pods. You can find these ConfigMaps by looking at which +ConfigMap is volume mounted by the Supervisor or Concierge Deployment. ```yaml -audit: - show_personally_identifiable_information: enabled - internal_endpoints: enabled -``` +apiVersion: v1 +kind: ConfigMap +metadata: # ... +data: + pinniped.yaml: | + # ...other settings -# + audit: + + # This setting is available in both the Supervisor and Concierge ConfigMaps. + # When enabled, usernames and group names determined during end-user auth + # will be audit logged. + logUsernamesAndGroups: enabled + + # This setting is only available in the Supervisor's ConfigMap. + # Enables audit logging of the /healthz endpoint. + logInternalPaths: enabled +``` ## Exporting Pinniped audit events off-cluster @@ -130,36 +138,264 @@ export only the audit event lines, or export the audit event lines separately fr This can be achieved by configuring Fluentbit `FILTER`s to evaluate each Supervisor or Concierge pod log line based on the presence or absence of the `"auditEvent"=true` key/value pair. +## Example of audit event logs -## TODO: Show audit events for a sample flow, in this case an LDAP browser flow +The follow example shows several audit event logs from the Supervisor's pod logs during an end user's browser-based +login using an OIDC identity provider. + +For this example, the `logUsernamesAndGroups` setting is enabled. If it were disabled, +all values in the `personalInfo` maps would be redacted. The pod logs contain one JSON object per line. +For readability, we have pretty-printed each line. + +The login flow starts with the client calling several discovery endpoints. +We will skip showing those audit logs here for brevity. + +Next, the client calls the authorize endpoint to start the login flow. +A single call to the authorize endpoint causes several audit log event, +which can be correlated using the `auditID` (request ID) to find all logs related to that single HTTPS request. +Note that potentially sensitive values such as credentials are automatically redacted in the logs. +The logs from the authorize endpoint are shown below. ```json lines -{"message":"HTTP Request Received","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"proto":"HTTP/2.0","method":"GET","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/authorize","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} -{"message":"HTTP Request Custom Headers Used","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"Pinniped-Username":false,"Pinniped-Password":false} -{"message":"HTTP Request Parameters","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"params":"access_type=offline&client_id=pinniped-cli&code_challenge=redacted&code_challenge_method=S256&nonce=redacted&pinniped_idp_name=My+LDAP+IDP+%F0%9F%9A%80&redirect_uri=http%3A%2F%2F127.0.0.1%3A52377%2Fcallback&response_mode=form_post&response_type=code&scope=groups+offline_access+openid+pinniped%3Arequest-audience+username&state=redacted"} -{"message":"Using Upstream IDP","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"displayName":"My LDAP IDP 🚀","resourceName":"my-ldap-provider","resourceUID":"e8006e7c-91d0-4aa5-b655-844fa2d4aaa4","type":"ldap"} -{"message":"Upstream Authorize Redirect","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} -{"message":"HTTP Request Completed","auditID":"c5c83810-17e6-4090-86f0-7bfa1d86c8e0","auditEvent":true,"path":"/some/path/oauth2/authorize","latency":"510.279µs","responseStatus":303,"location":"https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/login?state=redacted"} -{"message":"HTTP Request Received","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"proto":"HTTP/2.0","method":"GET","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/login","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} -{"message":"AuthorizeID From Parameters","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} -{"message":"HTTP Request Completed","auditID":"50b5e755-fb36-4cec-b343-9ba4cbc4d46f","auditEvent":true,"path":"/some/path/login","latency":"786.974µs","responseStatus":200,"location":"no location header"} -{"message":"HTTP Request Received","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/login","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36","remoteAddr":"10.244.0.17:50122"} -{"message":"AuthorizeID From Parameters","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"authorizeID":"9e9289b3e8b8480360dbfaddb86d91ca5e7c59a3ff3622ee1153cf2124cdee05"} -{"message":"Identity From Upstream IDP","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"upstreamIDPDisplayName":"My LDAP IDP 🚀","upstreamIDPType":"ldap","upstreamIDPResourceName":"my-ldap-provider","upstreamIDPResourceUID":"e8006e7c-91d0-4aa5-b655-844fa2d4aaa4","upstreamUsername":"pinny.ldap@example.com","upstreamGroups":["ball-game-players","seals"]} -{"message":"Session Started","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"username":"ldap:pinny.ldap@example.com","groups":["ldap:ball-admins","ldap:ball-game-players"],"subject":"ldaps://ldap.tools.svc.cluster.local?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=My+LDAP+IDP+%F0%9F%9A%80&sub=MTAwMA","additionalClaims":null,"warnings":[]} -{"message":"HTTP Request Completed","auditID":"3634195e-52b7-4beb-97d7-f881027251b3","auditEvent":true,"path":"/some/path/login","latency":"47.139942ms","responseStatus":200,"location":"no location header"} -{"message":"HTTP Request Received","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:41922"} -{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"params":"code=redacted&code_verifier=redacted&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%3A52377%2Fcallback"} -{"message":"HTTP Request Completed","auditID":"fd54a485-ee59-4c61-b05d-d5c86303f167","auditEvent":true,"path":"/some/path/oauth2/token","latency":"207.835054ms","responseStatus":200,"location":"no location header"} -{"message":"HTTP Request Received","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:41922"} -{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"params":"audience=my-workload-cluster-3b4294dd&client_id=pinniped-cli&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt&subject_token=redacted&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token"} -{"message":"HTTP Request Completed","auditID":"4aee9fbb-6163-4d55-a487-413549e6f746","auditEvent":true,"path":"/some/path/oauth2/token","latency":"183.118075ms","responseStatus":200,"location":"no location header"} -{"message":"HTTP Request Received","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:50346"} -{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"params":"grant_type=refresh_token&refresh_token=redacted"} -{"message":"Identity Refreshed From Upstream IDP","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"upstreamUsername":"pinny.ldap@example.com","upstreamGroups":["ball-game-players","seals"]} -{"message":"Session Refreshed","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"username":"ldap:pinny.ldap@example.com","groups":["ldap:ball-admins","ldap:ball-game-players"],"subject":"ldaps://ldap.tools.svc.cluster.local?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev&idpName=My+LDAP+IDP+%F0%9F%9A%80&sub=MTAwMA"} -{"message":"HTTP Request Completed","auditID":"6a7760aa-6ea8-4ceb-abf4-9215b976e9e4","auditEvent":true,"path":"/some/path/oauth2/token","latency":"41.358432ms","responseStatus":200,"location":"no location header"} -{"message":"HTTP Request Received","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"proto":"HTTP/2.0","method":"POST","host":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","serverName":"pinniped-supervisor-clusterip.supervisor.svc.cluster.local","path":"/some/path/oauth2/token","userAgent":"pinniped/v0.0.0 (darwin/amd64) kubernetes/$Format","remoteAddr":"10.244.0.17:50346"} -{"message":"HTTP Request Parameters","sessionID":"d4f6d184-fda2-4638-a44a-88c9484ba1d2","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"params":"audience=my-workload-cluster-3b4294dd&client_id=pinniped-cli&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt&subject_token=redacted&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token"} -{"message":"HTTP Request Completed","auditID":"6f00fd23-c932-4bd0-8102-86632c7e8ae0","auditEvent":true,"path":"/some/path/oauth2/token","latency":"2.993264ms","responseStatus":200,"location":"no location header"} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.162801Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", + "message": "HTTP Request Received", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "proto": "HTTP/2.0", + "method": "GET", + "host": "example-supervisor.pinniped.dev", + "serverName": "example-supervisor.pinniped.dev", + "path": "/oauth2/authorize", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "remoteAddr": "1.2.3.4:40262" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.162877Z", + "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", + "message": "HTTP Request Parameters", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "params": { + "access_type": "offline", + "client_id": "pinniped-cli", + "code_challenge": "redacted", + "code_challenge_method": "S256", + "nonce": "redacted", + "pinniped_idp_name": "My OIDC IDP", + "pinniped_idp_type": "oidc", + "redirect_uri": "http://127.0.0.1:55186/callback", + "response_mode": "form_post", + "response_type": "code", + "scope": "groups offline_access openid pinniped:request-audience username", + "state": "redacted" + } +} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.163006Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:116$auth.(*authorizeHandler).ServeHTTP", + "message": "HTTP Request Custom Headers Used", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "Pinniped-Username": false, + "Pinniped-Password": false +} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.163056Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:161$auth.(*authorizeHandler).ServeHTTP", + "message": "Using Upstream IDP", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "displayName": "My OIDC IDP", + "resourceName": "my-oidc-provider", + "resourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f", + "type": "oidc" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.163433Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:209$auth.(*authorizeHandler).authorize", + "message": "Upstream Authorize Redirect", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "authorizeID": "8129f3052a512881c72a329bb3044b8f39b7e9ed30e28f91b04d3917570b80e8" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:41:53.163464Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", + "message": "HTTP Request Completed", + "auditEvent": true, + "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "path": "/oauth2/authorize", + "latency": "671.792µs", + "responseStatus": 303, + "location": "https://example-external-oidc.pinniped.dev/auth?client_id=redacted&code_challenge=redacted&code_challenge_method=redacted&nonce=redacted&redirect_uri=redacted&response_type=redacted&scope=redacted&state=redacted" +} ``` + +As by the logs above, the authorize endpoint has redirected the user's browser to the external OIDC identity provider +for authentication. After the user authenticates there, the OIDC provider redirects back to the Supervisor's callback +endpoint. The `authorizeID` can be used to correlate the original authorize request with this callback request. +The logs from the callback request are shown below. + +```json lines +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.887705Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", + "message": "HTTP Request Received", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "proto": "HTTP/2.0", + "method": "GET", + "host": "example-supervisor.pinniped.dev", + "serverName": "example-supervisor.pinniped.dev", + "path": "/callback", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", + "remoteAddr": "1.2.3.4:40262" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.887769Z", + "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", + "message": "HTTP Request Parameters", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "params": { + "code": "redacted", + "state": "redacted" + } +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.887853Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/callback/callback_handler.go:52$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", + "message": "AuthorizeID From Parameters", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "authorizeID": "8129f3052a512881c72a329bb3044b8f39b7e9ed30e28f91b04d3917570b80e8" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.887872Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/callback/callback_handler.go:63$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", + "message": "Using Upstream IDP", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "displayName": "My OIDC IDP", + "resourceName": "my-oidc-provider", + "resourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f", + "type": "oidc" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.899166Z", + "caller": "go.pinniped.dev/internal/federationdomain/downstreamsession/downstream_session.go:53$downstreamsession.NewPinnipedSession", + "message": "Identity From Upstream IDP", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "personalInfo": { + "upstreamUsername": "pinny@example.com", + "upstreamGroups": [] + }, + "upstreamIDPDisplayName": "My OIDC IDP", + "upstreamIDPType": "oidc", + "upstreamIDPResourceName": "my-oidc-provider", + "upstreamIDPResourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.899243Z", + "caller": "go.pinniped.dev/internal/federationdomain/downstreamsession/downstream_session.go:120$downstreamsession.NewPinnipedSession", + "message": "Session Started", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "sessionID": "22a0fe9f-9cab-4248-8dac-bff71291b95c", + "personalInfo": { + "username": "oidc:pinny@example.com", + "groups": [], + "subject": "https://example-external-oidc.pinniped.dev?idpName=My+OIDC+IDP&sub=CiQwNjFkMjNkMS1mZTFlLTQ3NzctOWFlOS01OWNkMTJhYmVhYWESBWxvY2Fs", + "additionalClaims": {} + }, + "warnings": [] +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:11.909870Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", + "message": "HTTP Request Completed", + "auditEvent": true, + "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "path": "/callback", + "latency": "22.183042ms", + "responseStatus": 200, + "location": "no location header" +} +``` + +The callback endpoint started a Supervisor session for the user and sent an authorization code to the client. +Note that it logged a new unique `sessionID` for this user session. +Next, the client will call the token endpoint to exchange that code for tokens. This can be correlated to the +callback endpoint invocation using the `sessionID`. Additionally, all future activity related to this user session +can also be correlated using the `sessionID`, e.g. session refreshes, token exchanges, and session expiration. + +```json lines +{ + "level": "info", + "timestamp": "2024-11-14T18:42:15.190376Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", + "message": "HTTP Request Received", + "auditEvent": true, + "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "proto": "HTTP/2.0", + "method": "POST", + "host": "example-supervisor.pinniped.dev", + "serverName": "example-supervisor.pinniped.dev", + "path": "/oauth2/token", + "userAgent": "pinniped/v0.0.0 (darwin/arm64) kubernetes/$Format", + "remoteAddr": "1.2.3.4:42446" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:15.190475Z", + "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", + "message": "HTTP Request Parameters", + "auditEvent": true, + "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "params": { + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1:55186/callback" + } +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:15.190479Z", + "caller": "go.pinniped.dev/internal/federationdomain/endpoints/token/token_handler.go:68$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", + "message": "Session Found", + "auditEvent": true, + "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "sessionID": "22a0fe9f-9cab-4248-8dac-bff71291b95c" +} +{ + "level": "info", + "timestamp": "2024-11-14T18:42:15.396784Z", + "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", + "message": "HTTP Request Completed", + "auditEvent": true, + "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "path": "/oauth2/token", + "latency": "206.434458ms", + "responseStatus": 200, + "location": "no location header" +} +``` + +In a typical login, several more endpoints are called, but we omit them here for brevity. From 51fc86f950b891fa961546946e92806eb2cbd9c5 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 12:52:05 -0800 Subject: [PATCH 47/71] don't audit log missing username or password, change query param value Co-authored-by: Joshua Casey --- .../endpoints/auth/auth_handler.go | 1 - .../endpoints/login/get_login_handler_test.go | 4 ++-- .../endpoints/login/login_handler_test.go | 2 +- .../endpoints/login/post_login_handler.go | 4 ---- .../login/post_login_handler_test.go | 24 +------------------ .../endpoints/loginurl/login_url.go | 2 +- test/testlib/browsertest/browsertest.go | 2 +- 7 files changed, 6 insertions(+), 33 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index 03b6b481f..a0b2684c2 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -257,7 +257,6 @@ func (h *authorizeHandler) authorizeWithoutBrowser( ReqCtx: r.Context(), }) } - return err } diff --git a/internal/federationdomain/endpoints/login/get_login_handler_test.go b/internal/federationdomain/endpoints/login/get_login_handler_test.go index 7ff8a7714..50fa5bddd 100644 --- a/internal/federationdomain/endpoints/login/get_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/get_login_handler_test.go @@ -47,13 +47,13 @@ func TestGetLogin(t *testing.T) { wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, ""), // no alert message }, { - name: "displays error banner when err=login_error param is sent", + name: "displays error banner when err=incorrect_username_or_password param is sent", decodedState: &oidc.UpstreamStateParamData{ UpstreamName: testUpstreamName, UpstreamType: testUpstreamType, }, encodedState: testEncodedState, - errParam: "login_error", + errParam: "incorrect_username_or_password", wantStatus: http.StatusOK, wantContentType: htmlContentType, wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 9b7fd877a..75fa5cce9 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -363,7 +363,7 @@ func TestLoginEndpoint(t *testing.T) { wantAuditLogs: func(encodedStateParam stateparam.Encoded) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ - "params": map[string]any{"state": "redacted", "err": "login_error"}, + "params": map[string]any{"state": "redacted", "err": "incorrect_username_or_password"}, }), testutil.WantAuditLog("AuthorizeID From Parameters", map[string]any{ "authorizeID": encodedStateParam.AuthorizeID(), diff --git a/internal/federationdomain/endpoints/login/post_login_handler.go b/internal/federationdomain/endpoints/login/post_login_handler.go index 3dfdad220..230a67f1d 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler.go +++ b/internal/federationdomain/endpoints/login/post_login_handler.go @@ -77,10 +77,6 @@ func NewPostHandler( // Treat blank username or password as a bad username/password combination, as opposed to an internal error. if submittedUsername == "" || submittedPassword == "" { - auditLogger.Audit(auditevent.IncorrectUsernameOrPassword, &plog.AuditParams{ - ReqCtx: r.Context(), - }) - // User forgot to enter one of the required fields. // The user may try to log in again if they'd like, so redirect back to the login page with an error. return redirectToLoginPage(r, w, issuerURL, encodedState, loginurl.ShowBadUserPassErr) diff --git a/internal/federationdomain/endpoints/login/post_login_handler_test.go b/internal/federationdomain/endpoints/login/post_login_handler_test.go index 06cdc73f7..61872a302 100644 --- a/internal/federationdomain/endpoints/login/post_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/post_login_handler_test.go @@ -64,7 +64,7 @@ func TestPostLoginEndpoint(t *testing.T) { userParam = "username" passParam = "password" - badUserPassErrParamValue = "login_error" + badUserPassErrParamValue = "incorrect_username_or_password" internalErrParamValue = "internal_error" transformationUsernamePrefix = "username_prefix:" @@ -942,17 +942,6 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{ - testutil.WantAuditLog("Using Upstream IDP", map[string]any{ - "displayName": "some-ldap-idp", - "resourceName": "some-ldap-idp", - "resourceUID": "ldap-resource-uid", - "type": "ldap", - }), - testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), - } - }, }, { name: "blank password LDAP login", @@ -963,17 +952,6 @@ func TestPostLoginEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyString: "", wantRedirectToLoginPageError: badUserPassErrParamValue, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { - return []testutil.WantedAuditLog{ - testutil.WantAuditLog("Using Upstream IDP", map[string]any{ - "displayName": "some-ldap-idp", - "resourceName": "some-ldap-idp", - "resourceUID": "ldap-resource-uid", - "type": "ldap", - }), - testutil.WantAuditLog("Incorrect Username Or Password", map[string]any{}), - } - }, }, { name: "username and password sent as URI query params should be ignored since they are expected in form post body", diff --git a/internal/federationdomain/endpoints/loginurl/login_url.go b/internal/federationdomain/endpoints/loginurl/login_url.go index c64205eee..b37012c52 100644 --- a/internal/federationdomain/endpoints/loginurl/login_url.go +++ b/internal/federationdomain/endpoints/loginurl/login_url.go @@ -18,7 +18,7 @@ const ( ShowNoError ErrorParamValue = "" ShowInternalError ErrorParamValue = "internal_error" - ShowBadUserPassErr ErrorParamValue = "login_error" + ShowBadUserPassErr ErrorParamValue = "incorrect_username_or_password" ) type ErrorParamValue string diff --git a/test/testlib/browsertest/browsertest.go b/test/testlib/browsertest/browsertest.go index dd6db472d..0064e12ec 100644 --- a/test/testlib/browsertest/browsertest.go +++ b/test/testlib/browsertest/browsertest.go @@ -584,7 +584,7 @@ func WaitForUpstreamLDAPLoginPageWithError(t *testing.T, b *Browser, issuer stri // Wait for redirect back to the login page again with an error. t.Logf("waiting for redirect to back to login page with error message") - loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?err=login_error&state=.+\z`) + loginURLRegexp, err := regexp.Compile(`\A` + regexp.QuoteMeta(issuer+"/login") + `\?err=incorrect_username_or_password&state=.+\z`) require.NoError(t, err) b.WaitForURL(t, loginURLRegexp) From d0905c02ddfb2138637444dd0016c6f94ac103f0 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 13:07:26 -0800 Subject: [PATCH 48/71] use test helper in rest_test.go to reduce some duplication Co-authored-by: Joshua Casey --- internal/registry/clientsecretrequest/rest_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/registry/clientsecretrequest/rest_test.go b/internal/registry/clientsecretrequest/rest_test.go index bf9b05607..c1ee55ad2 100644 --- a/internal/registry/clientsecretrequest/rest_test.go +++ b/internal/registry/clientsecretrequest/rest_test.go @@ -720,7 +720,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-happy-new-secret", "generatedSecret": true, "revokedSecrets": float64(0), @@ -798,7 +797,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-append-new-secret-hash", "generatedSecret": true, "revokedSecrets": float64(0), @@ -873,7 +871,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-append-new-secret-hash", "generatedSecret": true, "revokedSecrets": float64(2), @@ -947,7 +944,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-some-client", "generatedSecret": false, "revokedSecrets": float64(2), @@ -1456,7 +1452,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-some-client", "generatedSecret": true, "revokedSecrets": float64(1), @@ -1534,7 +1529,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-some-client", "generatedSecret": true, "revokedSecrets": float64(5), @@ -1613,7 +1607,6 @@ func TestCreate(t *testing.T) { }, wantAuditLog: []testutil.WantedAuditLog{ testutil.WantAuditLog("OIDCClientSecretRequest Updated Secrets", map[string]any{ - "auditID": "fake-audit-id", "clientID": "client.oauth.pinniped.dev-some-client", "generatedSecret": true, "revokedSecrets": float64(6), @@ -1720,6 +1713,7 @@ func TestCreate(t *testing.T) { requireExactlyOneLogLineWithMultipleSteps(t, logger, tt.wantLogStepSubstrings) + testutil.WantAuditIDOnEveryAuditLog(tt.wantAuditLog, "fake-audit-id") testutil.CompareAuditLogs(t, tt.wantAuditLog, actualAuditLog.String()) }) } From 2de8d9f0f32b2f80411a938dd5fe881f58509867 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Nov 2024 14:06:53 -0800 Subject: [PATCH 49/71] cleanup example audit logs to make them prettier --- site/content/docs/reference/audit-logging.md | 27 +++++--------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index fe2ae070c..f26354f12 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -145,7 +145,9 @@ login using an OIDC identity provider. For this example, the `logUsernamesAndGroups` setting is enabled. If it were disabled, all values in the `personalInfo` maps would be redacted. The pod logs contain one JSON object per line. -For readability, we have pretty-printed each line. +For readability, we have pretty-printed each line. Also for readability, we have removed the `caller` key +in the example logs below. In the pod logs, every line includes `caller` and the value identifies the line of +code which caused the message to be logged. The login flow starts with the client calling several discovery endpoints. We will skip showing those audit logs here for brevity. @@ -160,7 +162,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.162801Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", "message": "HTTP Request Received", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -175,7 +176,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.162877Z", - "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", "message": "HTTP Request Parameters", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -197,7 +197,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.163006Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:116$auth.(*authorizeHandler).ServeHTTP", "message": "HTTP Request Custom Headers Used", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -207,7 +206,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.163056Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:161$auth.(*authorizeHandler).ServeHTTP", "message": "Using Upstream IDP", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -219,7 +217,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.163433Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/auth/auth_handler.go:209$auth.(*authorizeHandler).authorize", "message": "Upstream Authorize Redirect", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -228,7 +225,6 @@ The logs from the authorize endpoint are shown below. { "level": "info", "timestamp": "2024-11-14T18:41:53.163464Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", "message": "HTTP Request Completed", "auditEvent": true, "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", @@ -248,7 +244,6 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.887705Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", "message": "HTTP Request Received", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", @@ -263,7 +258,6 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.887769Z", - "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", "message": "HTTP Request Parameters", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", @@ -275,7 +269,6 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.887853Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/callback/callback_handler.go:52$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", "message": "AuthorizeID From Parameters", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", @@ -284,7 +277,6 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.887872Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/callback/callback_handler.go:63$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", "message": "Using Upstream IDP", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", @@ -296,13 +288,12 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.899166Z", - "caller": "go.pinniped.dev/internal/federationdomain/downstreamsession/downstream_session.go:53$downstreamsession.NewPinnipedSession", "message": "Identity From Upstream IDP", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", "personalInfo": { "upstreamUsername": "pinny@example.com", - "upstreamGroups": [] + "upstreamGroups": ["developers", "auditors"] }, "upstreamIDPDisplayName": "My OIDC IDP", "upstreamIDPType": "oidc", @@ -312,14 +303,13 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.899243Z", - "caller": "go.pinniped.dev/internal/federationdomain/downstreamsession/downstream_session.go:120$downstreamsession.NewPinnipedSession", "message": "Session Started", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", "sessionID": "22a0fe9f-9cab-4248-8dac-bff71291b95c", "personalInfo": { - "username": "oidc:pinny@example.com", - "groups": [], + "username": "pinny@example.com", + "groups": ["developers", "auditors"], "subject": "https://example-external-oidc.pinniped.dev?idpName=My+OIDC+IDP&sub=CiQwNjFkMjNkMS1mZTFlLTQ3NzctOWFlOS01OWNkMTJhYmVhYWESBWxvY2Fs", "additionalClaims": {} }, @@ -328,7 +318,6 @@ The logs from the callback request are shown below. { "level": "info", "timestamp": "2024-11-14T18:42:11.909870Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", "message": "HTTP Request Completed", "auditEvent": true, "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", @@ -349,7 +338,6 @@ can also be correlated using the `sessionID`, e.g. session refreshes, token exch { "level": "info", "timestamp": "2024-11-14T18:42:15.190376Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:83$requestlogger.(*requestLogger).logRequestReceived", "message": "HTTP Request Received", "auditEvent": true, "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", @@ -364,7 +352,6 @@ can also be correlated using the `sessionID`, e.g. session refreshes, token exch { "level": "info", "timestamp": "2024-11-14T18:42:15.190475Z", - "caller": "go.pinniped.dev/internal/plog/plog.go:207$plog.(*auditLogger).AuditRequestParams", "message": "HTTP Request Parameters", "auditEvent": true, "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", @@ -378,7 +365,6 @@ can also be correlated using the `sessionID`, e.g. session refreshes, token exch { "level": "info", "timestamp": "2024-11-14T18:42:15.190479Z", - "caller": "go.pinniped.dev/internal/federationdomain/endpoints/token/token_handler.go:68$endpointsmanager.(*Manager).SetFederationDomains.NewHandler.func7", "message": "Session Found", "auditEvent": true, "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", @@ -387,7 +373,6 @@ can also be correlated using the `sessionID`, e.g. session refreshes, token exch { "level": "info", "timestamp": "2024-11-14T18:42:15.396784Z", - "caller": "go.pinniped.dev/internal/federationdomain/requestlogger/request_logger.go:133$requestlogger.(*requestLogger).logRequestComplete", "message": "HTTP Request Completed", "auditEvent": true, "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", From 9c0272382fe9821a4343675bbf6ef877f3d546a4 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 15 Nov 2024 10:43:36 -0800 Subject: [PATCH 50/71] clean up audit logging documentation --- site/content/docs/reference/audit-logging.md | 100 ++++++++++-------- .../docs/reference/code-walkthrough.md | 8 +- 2 files changed, 58 insertions(+), 50 deletions(-) diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index f26354f12..e1acbe0c6 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -14,39 +14,37 @@ The Pinniped Supervisor and Pinniped Concierge components provide audit logging to help you meet your security and compliance standards. The configuration of the Pinniped Supervisor and Pinniped Concierge is managed by Kubernetes -custom resources and aggregated APIs. These resources and APIs are protected by the +custom resources. These resources are protected by the [standard Kubernetes authorization controls](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) and audited by the [standard Kubernetes audit logging](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/) capabilities. -In addition, Pinniped exposes several APIs to all end users to provide end-user authentication. -For these APIs, Pinniped offers additional audit logging capabilities. These additional audit logs appear in +Pinniped also offers additional audit logging capabilities. These additional audit logs appear in the pod logs of the Supervisor and Concierge pods. Each line of the pod logs is a JSON object. Although these audit events are interleaved with other pod log messages, they are identifiable by always -having an `"auditEvent"=true` key/value pair. +having an `"auditEvent":true` key-value pair. ## APIs that can emit Pinniped audit events to the pod logs -Both the Supervisor and the Concierge offer custom resource definitions (CRDs) for configuration, -which are protected by Kubernetes RBAC and typically only available for administrators to use. -End users typically cannot access these APIs, and they are not part of the authentication flows for end users. +Both the Supervisor and the Concierge offer several custom Kubernetes resources for configuration, +which are protected by Kubernetes RBAC and by default are only available for administrators to use. +These APIs are not part of the authentication flows for end users. Changes to these resources are audited by the standard Kubernetes audit logging. +Of these resources, only two will emit additional audit events into the Supervisor or Concierge pod logs. +These audit events can be cross-referenced to the standard Kubernetes audit logs using the value at the `auditID` +key, which will be the same value in the Supervisor or Concierge pod logs and in the Kubernetes audit logs for +a particular request to the resource. These resources are: +- The Supervisor's `OIDCClientSecretRequest` resource. This is used create client secrets for `OIDCClient` resources. + It will emit audit events into the Supervisor pod logs to describe the changes to client secrets saved by the request. +- The Concierge's `TokenCredendtialRequest` resource. This is used to authenticate a user and return a temporary + cluster credential for that user. It will emit audit events into the Concierge pod logs to describe the authentication + success or authentication failure of the request. -The Pinniped Supervisor offers one additional API for administrators, which is an aggregated API called -`OIDCClientSecretRequest` to create client secrets for `OIDCClient` resources. -End users typically cannot access this API (protected by Kubernetes RBAC), and it is not part of the authentication -flows for end users. This API is audited by the standard Kubernetes audit logging and may also emit Pinniped audit events. - -The Pinniped Concierge offers two public APIs for end-user authentication, which are both aggregated APIs. -These will be audited by the standard Kubernetes audit logging and may also emit Pinniped audit events. -- `TokenCredendtialRequest`: This API authenticates a user and returns a temporary cluster credential for that user. -- `WhoAmIRequest`: This API returns the username and group memberships of the user who invokes it. - -The Pinniped Supervisor offers several public APIs for end-user authentication for each configured `FederationDomain`. -These are not aggregated APIs, so they are not audited by the standard Kubernetes audit logging. -These will emit Pinniped audit events. Each request to these APIs may emit several audit events. -These APIs include: +Additionally, the Pinniped Supervisor offers several public APIs for end-user authentication for each +configured `FederationDomain`. These REST APIs are not represented as Kubernetes resources, +so they are not audited by the standard Kubernetes audit logging. These APIs will emit Pinniped audit events +into the Supervisor pod logs. Each request may emit several audit events. These APIs include: - `/.well-known/openid-configuration` is the standard OIDC discovery endpoint, which can be used to discover all the other endpoints listed here. - `/jwks.json` is the standard OIDC JWKS discovery endpoint. - `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. @@ -56,58 +54,62 @@ These APIs include: extended to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to reduce the applicable scope (technically, the `aud` claim) of ID tokens. - `/callback` is a special endpoint that is used as the redirect URL when performing an OAuth 2.0 or OIDC authcode flow against an upstream OIDC identity provider as configured by an `OIDCIdentityProvider` or `GitHubIdentityProvider` custom resource. -- `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. +- `/choose_identity_provider` is a UI page which allows users to choose which identity provider they would like to use during a browser-based login flow. +- `/login` is a UI page which prompts for username and password to support the optional browser-based login flow for LDAP and Active Directory identity providers. ## Structure of an audit event Every log line in a Supervisor or Concierge pod log is a JSON object. Only those log lines that include the -key/value pair `"auditEvent": true` are audit events. Other lines are for errors, warnings, and +key-value pair `"auditEvent":true` are audit events. Other lines are for errors, warnings, and debugging information. -Every line in the pod logs contains the following common keys/values, including audit event log lines: +Every line in the pod logs contains the following common keys and values, including audit event log lines: - `timestamp`, whose value is in UTC time, e.g. `2024-07-10T20:03:26.164470Z` - `level`, which for an audit event will always have the value `info` - `message`, which for audit events is effectively the audit event type, whose - value will always be one of the messages declared as an enum in `audit_events.go`, + value will always be one of the messages declared as an enum value in + [`audit_event.go`](https://github.com/vmware-tanzu/pinniped/blob/main/internal/auditevent/audit_event.go), which is effectively a catalog of all possible audit event types - `caller`, which is the line of Go code which caused the log - `stacktrace`, which is only included when the global log level is configured to `trace` or `all`, in which case the value shows a full Go stacktrace for the caller -Every audit event log line may also have the following keys/values, which are specifically designed to correlate -audit logs both forwards and backwards in time. The values for these keys are opaque and only used for correlation. +Some audit event log lines may also have the following keys and values, which are specifically designed to help +correlate an audit event log line to other logs. The values for these keys are opaque and only used for correlation. - When applicable, audit logs have an `auditID` which is a unique ID for every HTTP request, to allow multiple lines of audit events to be correlated when they came from a single HTTP request. This `auditID` is also returned to the client as an HTTP response header to allow for correlation between the request as observed by the client - and the logs as observed by the administrator. For aggregated APIs only, the `auditID` can also be used to - correlate Pinniped audit events with Kubernetes audit logs, which will use the same `auditID` value for a particular request. + and the logs as observed by the administrator. Only for `TokenCredendtialRequest` and `OIDCClientSecretRequest`, + the `auditID` can also be used to correlate Pinniped audit events with Kubernetes audit logs, which will use the + same `auditID` value for a particular request. - When applicable, audit logs have a `sessionID` which is the unique ID of a stored Pinniped Supervisor user session, to allow audit events to be correlated which relate to a single session even when they are caused by different - requests or controllers. The same `sessionID` can help you observe all the actions performed during a single user's - session across multiple HTTP requests that make up a fresh login, token exchanges, multiple session refreshes, and - session garbage collection. + HTTP requests or controllers. The same `sessionID` can help you observe all the actions performed during a single user's + session across multiple HTTP requests that make up a login, token exchanges, session refreshes, and session + expiration (garbage collection). - When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated across some of the browser redirects which relate to a single login attempt by an end user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. -Each audit event may also have more key/value pairs specific to the event's type. +Each audit event may also have more key-value pairs specific to the event's type. ## Configuration options for audit events -Audit events are always enabled. There are two configuration options available: +Logging of audit events is always enabled. There are two configuration options available: 1. By default, usernames and group names are not included in the audit events. This is because these names may include personally identifiable information (PII) which you may wish to avoid sending to your pod logs. - However, authentication audit logs can be more useful when this information is included. -2. By default, the Supervisor does not audit log requests made to the `healthz` endpoint, which is used for + However, authentication audit logs can be more useful when this information is included, so there is a + configuration option to enable it. +2. By default, the Supervisor does not audit log requests made to the `/healthz` endpoint, which is used for pod liveness and readiness probes, because it is called so often and it has no behavior other than returning OK. Both of these can be optionally enabled in the ConfigMaps which hold the pod startup settings for the Supervisor and Concierge deployments. When these ConfigMaps are changed, the corresponding Supervisor or Concierge pods must be restarted for the new settings to be picked up by the pods. You can find these ConfigMaps by looking at which -ConfigMap is volume mounted by the Supervisor or Concierge Deployment. +ConfigMap is volume-mounted by the Supervisor or Concierge Deployment. ```yaml apiVersion: v1 @@ -136,7 +138,7 @@ audit events appear in the pod logs, they will be exported along with the rest o Popular tools, like [Fluentbit](https://fluentbit.io), allow configuration options that could let you export only the audit event lines, or export the audit event lines separately from the other log lines. This can be achieved by configuring Fluentbit `FILTER`s to evaluate each Supervisor or Concierge pod log line -based on the presence or absence of the `"auditEvent"=true` key/value pair. +based on the presence or absence of the `"auditEvent":true` key-value pair. ## Example of audit event logs @@ -144,7 +146,7 @@ The follow example shows several audit event logs from the Supervisor's pod logs login using an OIDC identity provider. For this example, the `logUsernamesAndGroups` setting is enabled. If it were disabled, -all values in the `personalInfo` maps would be redacted. The pod logs contain one JSON object per line. +all values in the `personalInfo` maps shown below would be redacted. The pod logs contain one JSON object per line. For readability, we have pretty-printed each line. Also for readability, we have removed the `caller` key in the example logs below. In the pod logs, every line includes `caller` and the value identifies the line of code which caused the message to be logged. @@ -153,7 +155,7 @@ The login flow starts with the client calling several discovery endpoints. We will skip showing those audit logs here for brevity. Next, the client calls the authorize endpoint to start the login flow. -A single call to the authorize endpoint causes several audit log event, +A single call to the authorize endpoint causes several audit log events, which can be correlated using the `auditID` (request ID) to find all logs related to that single HTTPS request. Note that potentially sensitive values such as credentials are automatically redacted in the logs. The logs from the authorize endpoint are shown below. @@ -235,10 +237,10 @@ The logs from the authorize endpoint are shown below. } ``` -As by the logs above, the authorize endpoint has redirected the user's browser to the external OIDC identity provider +As shown by the logs above, the authorize endpoint has redirected the user's browser to the external OIDC identity provider for authentication. After the user authenticates there, the OIDC provider redirects back to the Supervisor's callback -endpoint. The `authorizeID` can be used to correlate the original authorize request with this callback request. -The logs from the callback request are shown below. +endpoint. The `authorizeID` can be used to correlate the logs from the original authorize request, shown above, +with the logs from this callback request, shown below. ```json lines { @@ -330,9 +332,11 @@ The logs from the callback request are shown below. The callback endpoint started a Supervisor session for the user and sent an authorization code to the client. Note that it logged a new unique `sessionID` for this user session. -Next, the client will call the token endpoint to exchange that code for tokens. This can be correlated to the -callback endpoint invocation using the `sessionID`. Additionally, all future activity related to this user session -can also be correlated using the `sessionID`, e.g. session refreshes, token exchanges, and session expiration. +Next, the client will call the token endpoint to exchange that authorization code for tokens. The requests to the +callback endpoint and the token endpoint can be correlated using the `sessionID`. +Additionally, all future activity related to this user session can also be correlated using the `sessionID`, +including session refreshes, token exchanges, and session expiration. +The logs from the token endpoint are shown below. ```json lines { @@ -383,4 +387,6 @@ can also be correlated using the `sessionID`, e.g. session refreshes, token exch } ``` -In a typical login, several more endpoints are called, but we omit them here for brevity. +In a typical login flow, several more endpoints are called, but we omit them here for brevity. As we've seen, +a user's entire authentication journey can be followed by using the `auditID`, `authorizeID`, and `sessionID` +correlation values to find related audit log events. diff --git a/site/content/docs/reference/code-walkthrough.md b/site/content/docs/reference/code-walkthrough.md index ed2d8c0d2..e30613371 100644 --- a/site/content/docs/reference/code-walkthrough.md +++ b/site/content/docs/reference/code-walkthrough.md @@ -200,6 +200,8 @@ The per-FederationDomain endpoints are: See [internal/federationdomain/endpoints/discovery/discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/discovery/discovery_handler.go). - `/jwks.json` is the standard OIDC JWKS discovery endpoint. See [internal/federationdomain/endpoints/jwks/jwks_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/jwks/jwks_handler.go). +- `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. + See [internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go). - `/oauth2/authorize` is the standard OIDC authorize endpoint. See [internal/federationdomain/endpoints/auth/auth_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/auth/auth_handler.go). - `/oauth2/token` is the standard OIDC token endpoint. @@ -210,9 +212,9 @@ The per-FederationDomain endpoints are: reduce the applicable scope (technically, the `aud` claim) of ID tokens. - `/callback` is a special endpoint that is used as the redirect URL when performing an OAuth 2.0 or OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider or GitHubIdentityProvider custom resource. See [internal/federationdomain/endpoints/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/callback/callback_handler.go). -- `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. - See [internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go). -- `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. +- `/choose_identity_provider` is a UI page which allows users to choose which identity provider they would like to use during a browser-based login flow. + See [internal/federationdomain/endpoints/chooseidp/choose_idp_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/chooseidp/choose_idp_handler.go). +- `/login` is a UI page which prompts for username and password to support the optional browser-based login flow for LDAP and Active Directory identity providers. See [internal/federationdomain/endpoints/login/login_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/login/login_handler.go). The OIDC specifications implemented by the Supervisor can be found at [openid.net](https://openid.net/connect). From 7d59df0f865c90f8e27b4a372106a5afe5eaaa00 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 15 Nov 2024 10:55:01 -0800 Subject: [PATCH 51/71] update original audit logging proposal --- proposals/1141_audit-logging/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/proposals/1141_audit-logging/README.md b/proposals/1141_audit-logging/README.md index aaf1d54b4..e51b23c66 100644 --- a/proposals/1141_audit-logging/README.md +++ b/proposals/1141_audit-logging/README.md @@ -1,11 +1,18 @@ --- title: "Audit Logging" authors: [ "@cfryanr" ] -status: "accepted" +status: "implemented" sponsor: [ ] -approval_date: "" +approval_date: "June 28, 2022" --- +*IMPORTANT NOTE*: This proposal was written in May 2022 and implemented much later in November 2024. +Due to changes in the Kubernetes ecosystem in the intervening years, this design underwent some +redesign before implementation. Please see the +[audit logging documentation](https://pinniped.dev/docs/reference/audit-logging/) +for a more accurate and up-to-date description of how audit logging +works. The document below is retained only for historical purposes. + *Disclaimer*: Proposals are point-in-time designs and decisions. Once approved and implemented, they become historical documents. If you are reading an old proposal, please be aware that the features described herein might have continued to evolve since. From b69507f7f3646e6182e4f120391f85215a8ecaff Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Fri, 15 Nov 2024 13:16:37 -0600 Subject: [PATCH 52/71] Add generic audit integration test --- test/integration/audit_test.go | 447 ++++++++++++++++++ test/integration/supervisor_discovery_test.go | 94 ++-- test/testlib/client.go | 2 +- test/testlib/supervisor_issuer.go | 4 + 4 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 test/integration/audit_test.go diff --git a/test/integration/audit_test.go b/test/integration/audit_test.go new file mode 100644 index 000000000..da429d2ec --- /dev/null +++ b/test/integration/audit_test.go @@ -0,0 +1,447 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" + + supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/test/testlib" +) + +// kubeClientWithoutPinnipedAPISuffix is much like testlib.NewKubernetesClientset but does not +// use middleware to change the Pinniped API suffix (kubeclient.WithMiddleware). +// +// The returned kubeclient is only for interacting with K8s-native objects, not Pinniped objects, +// so it does not need to be aware of Pinniped's API suffix. +func kubeClientWithoutPinnipedAPISuffix(t *testing.T) kubernetes.Interface { + t.Helper() + + client, err := kubeclient.New(kubeclient.WithConfig(testlib.NewClientConfig(t))) + require.NoError(t, err) + + return client.Kubernetes +} + +func TestAuditLogsEmittedForDiscoveryEndpoints_Parallel(t *testing.T) { + ctx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancelFunc() + + env, kubeClientForK8sResourcesOnly, fakeIssuerForDisplayPurposes, ca, dnsOverrides := auditSetup(t, ctx) + + startTime := metav1.Now() + //nolint:bodyclose // this is closed in the helper function + _, _, auditID := requireSuccessEndpointResponse( + t, + fakeIssuerForDisplayPurposes.Issuer()+"/.well-known/openid-configuration", + fakeIssuerForDisplayPurposes.Issuer(), + ca.Bundle(), + dnsOverrides, + ) + + allSupervisorPodLogsWithAuditID := getAuditLogsForAuditID( + t, + ctx, + auditID, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + startTime, + ) + + require.Equal(t, 2, len(allSupervisorPodLogsWithAuditID), + "expected exactly two log lines with auditID=%s", auditID) + + require.Equal(t, []map[string]any{ + { + "message": "HTTP Request Received", + "proto": "HTTP/1.1", + "method": "GET", + "host": fakeIssuerForDisplayPurposes.Address(), + "serverName": fakeIssuerForDisplayPurposes.Address(), + "path": "/federation/domain/for/auditing/.well-known/openid-configuration", + }, + { + "message": "HTTP Request Completed", + "path": "/federation/domain/for/auditing/.well-known/openid-configuration", + "responseStatus": float64(200), + "location": "no location header", + }, + }, allSupervisorPodLogsWithAuditID) +} + +// Certain endpoints will log their parameters with an "HTTP Request Parameters" audit event, +// although most values are redacted. This test sets up a failing call to each of the following: +// /oauth2/authorize, /callback, /login, and /oauth2/token. +func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *testing.T) { + ctx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancelFunc() + + env, kubeClientForK8sResourcesOnly, fakeIssuerForDisplayPurposes, ca, dnsOverrides := auditSetup(t, ctx) + + // Call the /oauth2/authorize endpoint + startTime := metav1.Now() + //nolint:bodyclose // this is closed in the helper function + _, _, auditID := requireEndpointResponse( + t, + fakeIssuerForDisplayPurposes.Issuer()+"/oauth2/authorize?foo=bar&foo=bar&scope=safe-to-log", + fakeIssuerForDisplayPurposes.Issuer(), + ca.Bundle(), + dnsOverrides, + http.StatusBadRequest, + ) + + allSupervisorPodLogsWithAuditID := getAuditLogsForAuditID( + t, + ctx, + auditID, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + startTime, + ) + + require.Equal(t, []map[string]any{ + { + "message": "HTTP Request Received", + "proto": "HTTP/1.1", + "method": "GET", + "host": fakeIssuerForDisplayPurposes.Address(), + "serverName": fakeIssuerForDisplayPurposes.Address(), + "path": "/federation/domain/for/auditing/oauth2/authorize", + }, + { + "message": "HTTP Request Parameters", + "multiValueParams": map[string]any{ + "foo": []any{"redacted", "redacted"}, + }, + "params": map[string]any{ + "scope": "safe-to-log", + "foo": "redacted", + }, + }, + { + "message": "HTTP Request Custom Headers Used", + "Pinniped-Password": false, + "Pinniped-Username": false, + }, + { + "message": "HTTP Request Completed", + "path": "/federation/domain/for/auditing/oauth2/authorize", + "responseStatus": float64(http.StatusBadRequest), + "location": "no location header", + }, + }, allSupervisorPodLogsWithAuditID) + + // Call the /callback endpoint + startTime = metav1.Now() + //nolint:bodyclose // this is closed in the helper function + _, _, auditID = requireEndpointResponse( + t, + fakeIssuerForDisplayPurposes.Issuer()+"/callback?foo=bar&foo=bar&error=safe-to-log", + fakeIssuerForDisplayPurposes.Issuer(), + ca.Bundle(), + dnsOverrides, + http.StatusForbidden, + ) + + allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( + t, + ctx, + auditID, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + startTime, + ) + + require.Equal(t, []map[string]any{ + { + "message": "HTTP Request Received", + "proto": "HTTP/1.1", + "method": "GET", + "host": fakeIssuerForDisplayPurposes.Address(), + "serverName": fakeIssuerForDisplayPurposes.Address(), + "path": "/federation/domain/for/auditing/callback", + }, + { + "message": "HTTP Request Parameters", + "multiValueParams": map[string]any{ + "foo": []any{"redacted", "redacted"}, + }, + "params": map[string]any{ + "error": "safe-to-log", + "foo": "redacted", + }, + }, + { + "message": "HTTP Request Completed", + "path": "/federation/domain/for/auditing/callback", + "responseStatus": float64(http.StatusForbidden), + "location": "no location header", + }, + }, allSupervisorPodLogsWithAuditID) + + // Call the /login endpoint + startTime = metav1.Now() + //nolint:bodyclose // this is closed in the helper function + _, _, auditID = requireEndpointResponse( + t, + fakeIssuerForDisplayPurposes.Issuer()+"/login?foo=bar&foo=bar&err=safe-to-log", + fakeIssuerForDisplayPurposes.Issuer(), + ca.Bundle(), + dnsOverrides, + http.StatusForbidden, + ) + + allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( + t, + ctx, + auditID, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + startTime, + ) + + require.Equal(t, []map[string]any{ + { + "message": "HTTP Request Received", + "proto": "HTTP/1.1", + "method": "GET", + "host": fakeIssuerForDisplayPurposes.Address(), + "serverName": fakeIssuerForDisplayPurposes.Address(), + "path": "/federation/domain/for/auditing/login", + }, + { + "message": "HTTP Request Parameters", + "multiValueParams": map[string]any{ + "foo": []any{"redacted", "redacted"}, + }, + "params": map[string]any{ + "err": "safe-to-log", + "foo": "redacted", + }, + }, + { + "message": "HTTP Request Completed", + "path": "/federation/domain/for/auditing/login", + "responseStatus": float64(http.StatusForbidden), + "location": "no location header", + }, + }, allSupervisorPodLogsWithAuditID) + + // Call the /oauth2/token endpoint + startTime = metav1.Now() + //nolint:bodyclose // this is closed in the helper function + _, _, auditID = requireEndpointResponse( + t, + fakeIssuerForDisplayPurposes.Issuer()+"/oauth2/token?foo=bar&foo=bar&grant_type=safe-to-log", + fakeIssuerForDisplayPurposes.Issuer(), + ca.Bundle(), + dnsOverrides, + http.StatusBadRequest, + ) + + allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( + t, + ctx, + auditID, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + startTime, + ) + + require.Equal(t, []map[string]any{ + { + "message": "HTTP Request Received", + "proto": "HTTP/1.1", + "method": "GET", + "host": fakeIssuerForDisplayPurposes.Address(), + "serverName": fakeIssuerForDisplayPurposes.Address(), + "path": "/federation/domain/for/auditing/oauth2/token", + }, + { + "message": "HTTP Request Parameters", + "multiValueParams": map[string]any{ + "foo": []any{"redacted", "redacted"}, + }, + "params": map[string]any{ + "grant_type": "safe-to-log", + "foo": "redacted", + }, + }, + { + "message": "HTTP Request Completed", + "path": "/federation/domain/for/auditing/oauth2/token", + "responseStatus": float64(http.StatusBadRequest), + "location": "no location header", + }, + }, allSupervisorPodLogsWithAuditID) +} + +func auditSetup(t *testing.T, ctx context.Context) ( + *testlib.TestEnv, + kubernetes.Interface, + *testlib.SupervisorIssuer, + *certauthority.CA, + map[string]string, +) { + env := testlib.IntegrationEnv(t).WithKubeDistribution(testlib.KindDistro) + + kubeClientForK8sResourcesOnly := kubeClientWithoutPinnipedAPISuffix(t) + + // Use a unique hostname so that it won't interfere with any other FederationDomain, + // which means this test can be run in _Parallel. + fakeHostname := "pinniped-" + strings.ToLower(testlib.RandHex(t, 8)) + ".example.com" + fakeIssuerForDisplayPurposes := testlib.NewSupervisorIssuer(t, "https://"+fakeHostname+"/federation/domain/for/auditing") + + // Generate a CA bundle with which to serve this provider. + t.Logf("generating test CA") + tlsServingCertForSupervisorSecretName := "federation-domain-serving-cert-" + testlib.RandHex(t, 8) + + ca := createTLSServingCertSecretForSupervisor( + ctx, + t, + env, + fakeIssuerForDisplayPurposes, + tlsServingCertForSupervisorSecretName, + kubeClientForK8sResourcesOnly, + ) + + // Create any IDP so that any FederationDomain created later by this test will see that exactly one IDP exists. + idp := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + + _ = testlib.CreateTestFederationDomain(ctx, t, + supervisorconfigv1alpha1.FederationDomainSpec{ + Issuer: fakeIssuerForDisplayPurposes.Issuer(), + TLS: &supervisorconfigv1alpha1.FederationDomainTLSSpec{ + SecretName: tlsServingCertForSupervisorSecretName, + }, + IdentityProviders: []supervisorconfigv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: idp.GetName(), + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: idp.GetName(), + }, + }, + }, + }, + supervisorconfigv1alpha1.FederationDomainPhaseReady, + ) + + // hostname and port WITHOUT SCHEME for direct access to the supervisor's port 8443 + physicalAddress := testlib.NewSupervisorIssuer(t, env.SupervisorHTTPSAddress).Address() + + dnsOverrides := map[string]string{ + fakeHostname + ":443": physicalAddress, + } + return env, kubeClientForK8sResourcesOnly, fakeIssuerForDisplayPurposes, ca, dnsOverrides +} + +func cleanupAuditLog(t *testing.T, m *map[string]any, auditID string) { + delete(*m, "caller") + delete(*m, "remoteAddr") + delete(*m, "userAgent") + delete(*m, "timestamp") + delete(*m, "latency") + require.Equal(t, (*m)["level"], "info") + delete(*m, "level") + require.Equal(t, (*m)["auditEvent"], true) + delete(*m, "auditEvent") + require.Equal(t, (*m)["auditID"], auditID) + delete(*m, "auditID") +} + +func getAuditLogsForAuditID( + t *testing.T, + ctx context.Context, + auditID string, + kubeClient kubernetes.Interface, + namespace string, + appName string, + startTime metav1.Time, +) []map[string]any { + t.Helper() + + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + "app": appName, + }.String(), + }) + require.NoError(t, err) + + var allPodLogsBuffer bytes.Buffer + for _, pod := range pods.Items { + _, err = io.Copy(&allPodLogsBuffer, getLogsForPodSince(t, ctx, kubeClient, pod, startTime)) + require.NoError(t, err) + } + + allPodLogs := strings.Split(allPodLogsBuffer.String(), "\n") + var allPodLogsWithAuditID []map[string]any + for _, podLog := range allPodLogs { + if strings.Contains(podLog, auditID) { + var deserialized map[string]any + err = json.Unmarshal([]byte(podLog), &deserialized) + require.NoError(t, err) + cleanupAuditLog(t, &deserialized, auditID) + + allPodLogsWithAuditID = append(allPodLogsWithAuditID, deserialized) + } + } + + return allPodLogsWithAuditID +} + +func getLogsForPodSince( + t *testing.T, + ctx context.Context, + kubeClient kubernetes.Interface, + pod corev1.Pod, + startTime metav1.Time, +) *bytes.Buffer { + t.Helper() + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req := kubeClient.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ + SinceTime: &startTime, + }) + body, err := req.Stream(ctx) + require.NoError(t, err) + + var buf bytes.Buffer + _, err = io.Copy(&buf, body) + require.NoError(t, err) + require.NoError(t, body.Close()) + + return &buf +} diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index dcd92ef49..0a5b8bac9 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -73,10 +73,10 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) { Name string Scheme string Address string - CABundle string + CABundle []byte }{ - {Name: "direct https", Scheme: "https", Address: env.SupervisorHTTPSAddress, CABundle: string(defaultCA.Bundle())}, - {Name: "ingress https", Scheme: "https", Address: env.SupervisorHTTPSIngressAddress, CABundle: env.SupervisorHTTPSIngressCABundle}, + {Name: "direct https", Scheme: "https", Address: env.SupervisorHTTPSAddress, CABundle: defaultCA.Bundle()}, + {Name: "ingress https", Scheme: "https", Address: env.SupervisorHTTPSIngressAddress, CABundle: []byte(env.SupervisorHTTPSIngressCABundle)}, } for _, test := range tests { @@ -219,7 +219,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { ) // Now that the Secret exists, we should be able to access the endpoints by hostname using the CA. - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1.Bundle()), issuer1, nil) + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, ca1.Bundle(), issuer1, nil) // Delete the default TLS secret as well err := kubeClient.CoreV1().Secrets(env.SupervisorNamespace).Delete(ctx, env.DefaultTLSCertSecretName(), metav1.DeleteOptions{}) @@ -251,7 +251,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { ) // Now that the Secret exists at the new name, we should be able to access the endpoints by hostname using the CA. - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, string(ca1update.Bundle()), issuer1, nil) + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, address, ca1update.Bundle(), issuer1, nil) // To test SNI virtual hosting, send requests to discovery endpoints when the public address is different from the issuer name. hostname2 := "some-issuer-host-and-port-that-doesnt-match-public-supervisor-address.com" @@ -278,7 +278,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { ) // Now that the Secret exists, we should be able to access the endpoints by hostname using the CA. - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, hostname2+":"+hostnamePort2, string(ca2.Bundle()), issuer2, map[string]string{ + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, hostname2+":"+hostnamePort2, ca2.Bundle(), issuer2, map[string]string{ hostname2 + ":" + hostnamePort2: address, }) } @@ -336,7 +336,7 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) { ) // Now that the Secret exists, we should be able to access the endpoints by IP address using the CA. - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil) + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, defaultCA.Bundle(), issuerUsingIPAddress, nil) // Create an FederationDomain with a spec.tls.secretName. certSecretName := "integration-test-cert-1" @@ -360,12 +360,12 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) { // Now that the Secret exists, we should be able to access the endpoints by hostname using the CA from the SNI cert. // Hostnames are case-insensitive, so the request should still work even if the case of the hostname is different // from the case of the issuer URL's hostname. - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, strings.ToUpper(hostname)+":"+port, string(certCA.Bundle()), issuerUsingHostname, nil) + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, strings.ToUpper(hostname)+":"+port, certCA.Bundle(), issuerUsingHostname, nil) if !supervisorIssuer.IsIPAddress() { // And we can still access the other issuer using the default cert, // except when we have an IP address, because in that case we just overwrote the default cert - _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, string(defaultCA.Bundle()), issuerUsingIPAddress, nil) + _ = requireStandardDiscoveryEndpointsAreWorking(t, scheme, ipWithPort, defaultCA.Bundle(), issuerUsingIPAddress, nil) } } @@ -492,7 +492,7 @@ func wellKnownURLForIssuer(scheme, host, path string) string { return fmt.Sprintf("%s://%s/%s/.well-known/openid-configuration", scheme, host, strings.TrimPrefix(path, "/")) } -func requireDiscoveryEndpointsAreNotFound(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string) { +func requireDiscoveryEndpointsAreNotFound(t *testing.T, supervisorScheme, supervisorAddress string, supervisorCABundle []byte, issuerName string) { t.Helper() issuerURL, err := url.Parse(issuerName) require.NoError(t, err) @@ -500,7 +500,7 @@ func requireDiscoveryEndpointsAreNotFound(t *testing.T, supervisorScheme, superv requireEndpointNotFound(t, jwksURLForIssuer(supervisorScheme, supervisorAddress, issuerURL.Path), issuerURL.Host, supervisorCABundle) } -func requireEndpointNotFound(t *testing.T, url, host, caBundle string) { +func requireEndpointNotFound(t *testing.T, url, host string, caBundle []byte) { t.Helper() httpClient := newHTTPClient(t, caBundle, nil) @@ -555,7 +555,8 @@ func requireEndpointHasBootstrapTLSErrorBecauseCertificatesAreNotReady(t *testin func requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear( ctx context.Context, t *testing.T, - supervisorScheme, supervisorAddress, supervisorCABundle string, + supervisorScheme, supervisorAddress string, + supervisorCABundle []byte, issuerName string, client supervisorclientset.Interface, ) (*supervisorconfigv1alpha1.FederationDomain, *ExpectedJWKSResponseFormat) { @@ -566,7 +567,7 @@ func requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear( return newFederationDomain, jwksResult } -func requireStandardDiscoveryEndpointsAreWorking(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat { +func requireStandardDiscoveryEndpointsAreWorking(t *testing.T, supervisorScheme, supervisorAddress string, supervisorCABundle []byte, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat { requireWellKnownEndpointIsWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, dnsOverrides) jwksResult := requireJWKSEndpointIsWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, dnsOverrides) return jwksResult @@ -577,7 +578,8 @@ func requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear( existingFederationDomain *supervisorconfigv1alpha1.FederationDomain, client supervisorclientset.Interface, ns string, - supervisorScheme, supervisorAddress, supervisorCABundle string, + supervisorScheme, supervisorAddress string, + supervisorCABundle []byte, issuerName string, ) { t.Helper() @@ -592,11 +594,11 @@ func requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear( requireDiscoveryEndpointsAreNotFound(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName) } -func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string, dnsOverrides map[string]string) { +func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, supervisorAddress string, supervisorCABundle []byte, issuerName string, dnsOverrides map[string]string) { t.Helper() issuerURL, err := url.Parse(issuerName) require.NoError(t, err) - response, responseBody := requireSuccessEndpointResponse(t, wellKnownURLForIssuer(supervisorScheme, supervisorAddress, issuerURL.Path), issuerName, supervisorCABundle, dnsOverrides) //nolint:bodyclose + response, responseBody, _ := requireSuccessEndpointResponse(t, wellKnownURLForIssuer(supervisorScheme, supervisorAddress, issuerURL.Path), issuerName, supervisorCABundle, dnsOverrides) //nolint:bodyclose // Check that the response matches our expectations. expectedResultTemplate := here.Doc(`{ @@ -624,12 +626,12 @@ type ExpectedJWKSResponseFormat struct { Keys []map[string]string } -func requireJWKSEndpointIsWorking(t *testing.T, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat { +func requireJWKSEndpointIsWorking(t *testing.T, supervisorScheme, supervisorAddress string, supervisorCABundle []byte, issuerName string, dnsOverrides map[string]string) *ExpectedJWKSResponseFormat { t.Helper() issuerURL, err := url.Parse(issuerName) require.NoError(t, err) - response, responseBody := requireSuccessEndpointResponse(t, //nolint:bodyclose + response, responseBody, _ := requireSuccessEndpointResponse(t, //nolint:bodyclose jwksURLForIssuer(supervisorScheme, supervisorAddress, issuerURL.Path), issuerName, supervisorCABundle, @@ -664,14 +666,18 @@ func printServerCert(t *testing.T, address string, dnsOverrides map[string]strin addressURL, err := url.Parse(address) require.NoError(t, err) - host := addressURL.Host - if _, ok := dnsOverrides[host]; ok { - host = dnsOverrides[host] + require.Equal(t, "https", addressURL.Scheme, + "can only print server certificates for TLS-enabled endpoints") + + if !strings.Contains(addressURL.Host, ":") { + // tls.Dial() requires a port number, but there was no port number in the host, so assume 443. + addressURL.Host += ":443" } - if !strings.Contains(host, ":") { - // tls.Dial() requires a port number, but there was no port number in the host, so assume 443. - host += ":443" + host := addressURL.Host + if _, ok := dnsOverrides[host]; ok { + t.Logf("printServerCert replacing addr %s with %s", host, dnsOverrides[host]) + host = dnsOverrides[host] } conn, err := tls.Dial("tcp", host, conf) @@ -688,7 +694,13 @@ func printServerCert(t *testing.T, address string, dnsOverrides map[string]strin } } -func requireSuccessEndpointResponse(t *testing.T, endpointURL, issuer, caBundle string, dnsOverrides map[string]string) (*http.Response, string) { +func requireEndpointResponse( + t *testing.T, + endpointURL, issuer string, + caBundle []byte, + dnsOverrides map[string]string, + wantStatusCode int, +) (*http.Response, string, string) { t.Helper() httpClient := newHTTPClient(t, caBundle, dnsOverrides) @@ -714,6 +726,7 @@ func requireSuccessEndpointResponse(t *testing.T, endpointURL, issuer, caBundle // Set the host header on the request to match the issuer's hostname, which could potentially be different // from the public ingress address, e.g. when a load balancer is used, so we want to test here that the host // header is respected by the supervisor server. + // TODO: Why is this set? requestDiscoveryEndpoint.Host = issuerURL.Host printServerCert(t, endpointURL, dnsOverrides) @@ -722,8 +735,9 @@ func requireSuccessEndpointResponse(t *testing.T, endpointURL, issuer, caBundle requireEventually.NoError(err) defer func() { _ = response.Body.Close() }() - t.Logf("successful GET requestDiscoveryEndpoint=%q, found serverName=%s, with %d certificates", + t.Logf("GET requestDiscoveryEndpoint=%q, statusCode=%d, found serverName=%s, with %d certificates", requestDiscoveryEndpoint.URL.String(), + response.StatusCode, response.TLS.ServerName, len(response.TLS.PeerCertificates)) for _, peerCertificate := range response.TLS.PeerCertificates { @@ -732,13 +746,21 @@ func requireSuccessEndpointResponse(t *testing.T, endpointURL, issuer, caBundle peerCertificate.IPAddresses) } - requireEventually.Equal(http.StatusOK, response.StatusCode) + requireEventually.Equal(wantStatusCode, response.StatusCode) responseBody, err = io.ReadAll(response.Body) requireEventually.NoError(err) }, 2*time.Minute, 200*time.Millisecond) - return response, string(responseBody) + require.NotNil(t, response) + auditID := response.Header.Get("Audit-Id") + require.NotEmpty(t, auditID) + + return response, string(responseBody), auditID +} + +func requireSuccessEndpointResponse(t *testing.T, endpointURL, issuer string, caBundle []byte, dnsOverrides map[string]string) (*http.Response, string, string) { + return requireEndpointResponse(t, endpointURL, issuer, caBundle, dnsOverrides, http.StatusOK) } func editFederationDomainIssuerName( @@ -824,7 +846,7 @@ func requireStatus(t *testing.T, client supervisorclientset.Interface, ns, name }, 5*time.Minute, 200*time.Millisecond) } -func newHTTPClient(t *testing.T, caBundle string, dnsOverrides map[string]string) *http.Client { +func newHTTPClient(t *testing.T, caBundle []byte, dnsOverrides map[string]string) *http.Client { c := &http.Client{} realDialer := &net.Dialer{} @@ -834,14 +856,14 @@ func newHTTPClient(t *testing.T, caBundle string, dnsOverrides map[string]string t.Logf("DialContext replacing addr %s with %s", addr, replacementAddr) addr = replacementAddr } else if dnsOverrides != nil { - t.Fatal("dnsOverrides was provided but not used, which was probably a mistake") + t.Fatalf("dnsOverrides was provided but not used, which was probably a mistake. addr %s", addr) } return realDialer.DialContext(ctx, network, addr) } - if caBundle != "" { // CA bundle is optional + if len(caBundle) > 0 { // CA bundle is optional caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM([]byte(caBundle)) + caCertPool.AppendCertsFromPEM(caBundle) c.Transport = &http.Transport{ DialContext: overrideDialContext, TLSClientConfig: &tls.Config{MinVersion: ptls.SecureTLSConfigMinTLSVersion, RootCAs: caCertPool}, //nolint:gosec // this seems to be a false flag, min tls version is 1.3 in normal mode or 1.2 in fips mode @@ -860,7 +882,9 @@ func requireIDPsListedByIDPDiscoveryEndpoint( env *testlib.TestEnv, ctx context.Context, kubeClient kubernetes.Interface, - ns, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName string) *supervisorconfigv1alpha1.FederationDomain { + ns, supervisorScheme, supervisorAddress string, + supervisorCABundle []byte, + issuerName string) *supervisorconfigv1alpha1.FederationDomain { // github gitHubIDPSecretName := "github-idp-secret" //nolint:gosec // this is not a credential _, err := kubeClient.CoreV1().Secrets(ns).Create(ctx, &corev1.Secret{ @@ -999,7 +1023,7 @@ func requireIDPsListedByIDPDiscoveryEndpoint( issuer8URL, err := url.Parse(issuerName) require.NoError(t, err) wellKnownURL := wellKnownURLForIssuer(supervisorScheme, supervisorAddress, issuer8URL.Path) - _, wellKnownResponseBody := requireSuccessEndpointResponse(t, wellKnownURL, issuerName, supervisorCABundle, nil) //nolint:bodyclose + _, wellKnownResponseBody, _ := requireSuccessEndpointResponse(t, wellKnownURL, issuerName, supervisorCABundle, nil) //nolint:bodyclose type WellKnownResponse struct { Issuer string `json:"issuer"` @@ -1014,7 +1038,7 @@ func requireIDPsListedByIDPDiscoveryEndpoint( err = json.Unmarshal([]byte(wellKnownResponseBody), &wellKnownResponse) require.NoError(t, err) discoveryIDPEndpoint := wellKnownResponse.DiscoverySupervisor.IdentityProvidersEndpoint - _, discoveryIDPResponseBody := requireSuccessEndpointResponse(t, discoveryIDPEndpoint, issuerName, supervisorCABundle, nil) //nolint:bodyclose + _, discoveryIDPResponseBody, _ := requireSuccessEndpointResponse(t, discoveryIDPEndpoint, issuerName, supervisorCABundle, nil) //nolint:bodyclose type IdentityProviderListResponse struct { IdentityProviders []struct { Name string `json:"name"` diff --git a/test/testlib/client.go b/test/testlib/client.go index 01fa87b4f..4f6db331c 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -374,7 +374,7 @@ func CreateTestFederationDomain( federationDomainsClient := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace) federationDomain, err := federationDomainsClient.Create(createContext, &supervisorconfigv1alpha1.FederationDomain{ - ObjectMeta: TestObjectMeta(t, "oidc-provider"), + ObjectMeta: TestObjectMeta(t, "federation-domain"), Spec: spec, }, metav1.CreateOptions{}) require.NoError(t, err, "could not create test FederationDomain") diff --git a/test/testlib/supervisor_issuer.go b/test/testlib/supervisor_issuer.go index ee1479978..b4f682544 100644 --- a/test/testlib/supervisor_issuer.go +++ b/test/testlib/supervisor_issuer.go @@ -37,6 +37,10 @@ func NewSupervisorIssuer(t *testing.T, issuer string) *SupervisorIssuer { } } +func (s *SupervisorIssuer) AddPathSuffix(path string) { + s.issuerURL.Path += path +} + // AddAlternativeName adds a SAN for the cert. It is not intended to take an IP address as its argument. func (s *SupervisorIssuer) AddAlternativeName(san string) { s.alternativeNames = append(s.alternativeNames, san) From 60bd118a9c314eab49b64b799ffe75df52e8966d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Mon, 18 Nov 2024 16:30:07 -0600 Subject: [PATCH 53/71] pinniped CLI should print the audit-ID in certain error cases Co-authored-by: Ryan Richard --- internal/kubeclient/roundtrip.go | 2 +- pkg/oidcclient/login.go | 47 +++++++++++++++++++++++++++++++- test/testlib/env.go | 2 +- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/internal/kubeclient/roundtrip.go b/internal/kubeclient/roundtrip.go index e95c492aa..463d0b853 100644 --- a/internal/kubeclient/roundtrip.go +++ b/internal/kubeclient/roundtrip.go @@ -204,7 +204,7 @@ func handleCreateOrUpdate( negotiatedSerializer runtime.NegotiatedSerializer, ) (bool, *http.Response, error) { if req.GetBody == nil { - return true, nil, fmt.Errorf("unreadible body for request: %#v", middlewareReq) // this should never happen + return true, nil, fmt.Errorf("unreadable body for request: %#v", middlewareReq) // this should never happen } body, err := req.GetBody() diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index f1b7e6d71..27f4f7e37 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -33,6 +33,7 @@ import ( oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" + "go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/net/phttp" "go.pinniped.dev/internal/plog" @@ -357,6 +358,40 @@ type nopCache struct{} func (*nopCache) GetToken(SessionCacheKey) *oidctypes.Token { return nil } func (*nopCache) PutToken(SessionCacheKey, *oidctypes.Token) {} +// maybePrintAuditID will choose to log the auditID when certain failure cases are detected, +// to give a breadcrumb for an admin to follow. +// Older Supervisors and other OIDC identity providers may not provide this header. +func maybePrintAuditID(rt http.RoundTripper) http.RoundTripper { + return roundtripper.WrapFunc(rt, func(r *http.Request) (*http.Response, error) { + path := r.URL.Path + response, responseErr := rt.RoundTrip(r) + if response != nil && response.Header.Get("audit-ID") != "" { + switch { + case response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest: + // failing oauth2/authorize redirects from audit-enabled Supervisors + + location, err := url.Parse(response.Header.Get(httpLocationHeaderName)) + if err != nil || location.Query().Get("error") != "" { + plog.Info("Received auditID for failed request", + "path", path, + "statusCode", response.StatusCode, + "auditID", response.Header.Get("audit-ID")) + } + case response.StatusCode >= http.StatusBadRequest: + // failing discovery, oauth2/authorize, or oauth2/token responses from audit-enabled Supervisors + + plog.Info("Received auditID for failed request", + "path", path, + "statusCode", response.StatusCode, + "auditID", response.Header.Get("audit-ID")) + default: + // noop + } + } + return response, responseErr + }) +} + // Login performs an OAuth2/OIDC authorization code login using a localhost listener. func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, error) { h := handlerState{ @@ -379,7 +414,15 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er getEnv: os.Getenv, listen: net.Listen, stdinIsTTY: func() bool { return term.IsTerminal(stdin()) }, - getProvider: upstreamoidc.New, + getProvider: func(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { + // can't use upstreamoidc.New here since it does not set the Name + return &upstreamoidc.ProviderConfig{ + Name: issuer, // use the issuer as the Name + Config: config, + Provider: provider, + Client: client, + } + }, validateIDToken: func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error) { return provider.Verifier(&coreosoidc.Config{ClientID: audience}).Verify(ctx, token) }, @@ -393,6 +436,8 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er } } + h.httpClient.Transport = maybePrintAuditID(h.httpClient.Transport) + if h.cliToSendCredentials { if h.loginFlow != "" { return nil, fmt.Errorf("do not use deprecated option WithCLISendingCredentials when using option WithLoginFlow") diff --git a/test/testlib/env.go b/test/testlib/env.go index 194097db5..bb6a3e52c 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -401,7 +401,7 @@ func (e *TestEnv) WithoutCapability(cap Capability) *TestEnv { // Please use this sparingly. We would prefer that a test run on every cluster type where it can possibly run, so // prefer to run everywhere when possible or use cluster capabilities when needed, rather than looking at the // type of cluster to decide to skip a test. However, there are some tests that do not depend on or interact with -// Kubernetes itself which really only need to run on on a single platform to give us the coverage that we desire. +// Kubernetes itself which really only need to run on a single platform to give us the coverage that we desire. func (e *TestEnv) WithKubeDistribution(distro KubeDistro) *TestEnv { e.t.Helper() if e.KubernetesDistribution != distro { From 26ec7fa346e00a7c230785dfd33e6e9f6b66d980 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 18 Nov 2024 15:21:32 -0800 Subject: [PATCH 54/71] prepare-supervisor-on-kind.sh takes new --api-group-suffix flag Co-authored-by: Joshua Casey --- hack/prepare-supervisor-on-kind.sh | 58 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/hack/prepare-supervisor-on-kind.sh b/hack/prepare-supervisor-on-kind.sh index 3a56ea268..cbf5cac96 100755 --- a/hack/prepare-supervisor-on-kind.sh +++ b/hack/prepare-supervisor-on-kind.sh @@ -44,8 +44,20 @@ use_ldap_upstream=no use_ad_upstream=no use_github_upstream=no use_flow="" +api_group_suffix="pinniped.dev" # same default as in the values.yaml ytt file + while (("$#")); do case "$1" in + -g | --api-group-suffix) + shift + # If there are no more command line arguments, or there is another command line argument but it starts with a dash, then error + if [[ "$#" == "0" || "$1" == -* ]]; then + log_error "-g|--api-group-suffix requires a group name to be specified" + exit 1 + fi + api_group_suffix=$1 + shift + ;; --flow) shift # If there are no more command line arguments, or there is another command line argument but it starts with a dash, then error @@ -183,7 +195,7 @@ fi if [[ "$use_oidc_upstream" == "yes" ]]; then # Make an OIDCIdentityProvider which uses Dex to provide identity. cat <$fd_file -apiVersion: config.supervisor.pinniped.dev/v1alpha1 +apiVersion: config.supervisor.${api_group_suffix}/v1alpha1 kind: FederationDomain metadata: name: my-federation-domain @@ -368,7 +380,7 @@ if [[ "$use_oidc_upstream" == "yes" ]]; then - displayName: "My OIDC IDP 🚀" objectRef: - apiGroup: idp.supervisor.pinniped.dev + apiGroup: idp.supervisor.${api_group_suffix} kind: OIDCIdentityProvider name: my-oidc-provider transforms: @@ -392,7 +404,7 @@ if [[ "$use_ldap_upstream" == "yes" ]]; then - displayName: "My LDAP IDP 🚀" objectRef: - apiGroup: idp.supervisor.pinniped.dev + apiGroup: idp.supervisor.${api_group_suffix} kind: LDAPIdentityProvider name: my-ldap-provider transforms: # these are contrived to exercise all the available features @@ -446,7 +458,7 @@ if [[ "$use_ad_upstream" == "yes" ]]; then - displayName: "My AD IDP 🚀" objectRef: - apiGroup: idp.supervisor.pinniped.dev + apiGroup: idp.supervisor.${api_group_suffix} kind: ActiveDirectoryIdentityProvider name: my-ad-provider EOF @@ -458,7 +470,7 @@ if [[ "$use_github_upstream" == "yes" ]]; then - displayName: "My GitHub IDP 🚀" objectRef: - apiGroup: idp.supervisor.pinniped.dev + apiGroup: idp.supervisor.${api_group_suffix} kind: GitHubIdentityProvider name: my-github-provider EOF @@ -501,7 +513,7 @@ fi # The issuer URL must be accessible from within the cluster for OIDC discovery. echo "Creating JWTAuthenticator..." cat <kubeconfig-oidc.yaml + ./pinniped get kubeconfig --concierge-api-group-suffix "$api_group_suffix" \ + --oidc-skip-browser $flow_arg --upstream-identity-provider-type oidc >kubeconfig-oidc.yaml fi if [[ "$use_ldap_upstream" == "yes" ]]; then echo "Generating LDAP kubeconfig..." https_proxy="$proxy_server" no_proxy="$proxy_except" \ - ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type ldap >kubeconfig-ldap.yaml + ./pinniped get kubeconfig --concierge-api-group-suffix "$api_group_suffix" \ + --oidc-skip-browser $flow_arg --upstream-identity-provider-type ldap >kubeconfig-ldap.yaml fi if [[ "$use_ad_upstream" == "yes" ]]; then echo "Generating AD kubeconfig..." https_proxy="$proxy_server" no_proxy="$proxy_except" \ - ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type activedirectory >kubeconfig-ad.yaml + ./pinniped get kubeconfig --concierge-api-group-suffix "$api_group_suffix" \ + --oidc-skip-browser $flow_arg --upstream-identity-provider-type activedirectory >kubeconfig-ad.yaml fi if [[ "$use_github_upstream" == "yes" ]]; then echo "Generating GitHub kubeconfig..." https_proxy="$proxy_server" no_proxy="$proxy_except" \ - ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type github >kubeconfig-github.yaml + ./pinniped get kubeconfig --concierge-api-group-suffix "$api_group_suffix" \ + --oidc-skip-browser $flow_arg --upstream-identity-provider-type github >kubeconfig-github.yaml fi # Clear the local CLI cache to ensure that the kubectl command below will need to perform a fresh login. @@ -559,6 +575,12 @@ rm -f "$HOME/.config/pinniped/credentials.yaml" echo echo "Ready! 🚀" +if [[ "$api_group_suffix" == "pinniped.dev" ]]; then + api_group_flag="" +else + api_group_flag=" --api-group-suffix $api_group_suffix" +fi + # These instructions only apply when you are not using Contour and you will need a browser to log in. if [[ "${PINNIPED_USE_CONTOUR:-}" == "" && ("$use_oidc_upstream" == "yes" || "$use_flow" == "browser_authcode") ]]; then echo @@ -601,21 +623,21 @@ fi # they expire, so you should not be prompted to log in again for the rest of the day. if [[ "$use_oidc_upstream" == "yes" ]]; then echo "To log in using OIDC:" - echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-oidc.yaml" + echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-oidc.yaml${api_group_flag}" echo fi if [[ "$use_ldap_upstream" == "yes" ]]; then echo "To log in using LDAP:" - echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-ldap.yaml" + echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-ldap.yaml${api_group_flag}" echo fi if [[ "$use_ad_upstream" == "yes" ]]; then echo "To log in using AD:" - echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-ad.yaml" + echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-ad.yaml${api_group_flag}" echo fi if [[ "$use_github_upstream" == "yes" ]]; then echo "To log in using GitHub:" - echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-github.yaml" + echo "PINNIPED_DEBUG=true ${proxy_env_vars}./pinniped whoami --kubeconfig ./kubeconfig-github.yaml${api_group_flag}" echo fi From 6bf9b647784ac6f4c4f5bd35a83982c46df49414 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 18 Nov 2024 15:23:31 -0800 Subject: [PATCH 55/71] log response audit-id for tokencredentialrequests made from CLI Only logged when PINNIPED_DEBUG=true is used. Co-authored-by: Joshua Casey --- cmd/pinniped/cmd/audit_id.go | 26 +++++++++++++++++++++ cmd/pinniped/cmd/login_oidc.go | 1 + cmd/pinniped/cmd/login_oidc_test.go | 12 +++++----- cmd/pinniped/cmd/login_static.go | 1 + cmd/pinniped/cmd/login_static_test.go | 2 +- internal/kubeclient/roundtrip.go | 32 +++++++++++++------------- pkg/conciergeclient/conciergeclient.go | 21 +++++++++++++---- 7 files changed, 68 insertions(+), 27 deletions(-) create mode 100644 cmd/pinniped/cmd/audit_id.go diff --git a/cmd/pinniped/cmd/audit_id.go b/cmd/pinniped/cmd/audit_id.go new file mode 100644 index 000000000..591c7a895 --- /dev/null +++ b/cmd/pinniped/cmd/audit_id.go @@ -0,0 +1,26 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "net/http" + + "go.pinniped.dev/internal/httputil/roundtripper" + "go.pinniped.dev/internal/plog" +) + +func LogAuditIDTransportWrapper(rt http.RoundTripper) http.RoundTripper { + return roundtripper.WrapFunc(rt, func(r *http.Request) (*http.Response, error) { + response, responseErr := rt.RoundTrip(r) + if response != nil && response.Header.Get("audit-ID") != "" { + plog.Info("Received auditID for request", + // Use the request path from the response's request, in case the + // original request was modified by any other roudtrippers in the chain. + "path", response.Request.URL.Path, + "statusCode", response.StatusCode, + "auditID", response.Header.Get("audit-ID")) + } + return response, responseErr + }) +} diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 877a39e85..5adabe2ac 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -224,6 +224,7 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin conciergeclient.WithBase64CABundle(flags.conciergeCABundle), conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), conciergeclient.WithAPIGroupSuffix(flags.conciergeAPIGroupSuffix), + conciergeclient.WithTransportWrapper(LogAuditIDTransportWrapper), ) if err != nil { return fmt.Errorf("invalid Concierge parameters: %w", err) diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index 88796383f..10a87fddd 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -274,8 +274,8 @@ func TestLoginOIDCCommand(t *testing.T) { wantOptionsCount: 4, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"expirationTimestamp":"3020-10-12T13:14:15Z","token":"test-id-token"}}` + "\n", wantLogs: []string{ - nowStr + ` cmd/login_oidc.go:267 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`, - nowStr + ` cmd/login_oidc.go:287 No concierge configured, skipping token credential exchange`, + nowStr + ` cmd/login_oidc.go:268 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`, + nowStr + ` cmd/login_oidc.go:288 No concierge configured, skipping token credential exchange`, }, }, { @@ -319,10 +319,10 @@ func TestLoginOIDCCommand(t *testing.T) { wantOptionsCount: 12, wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{"interactive":false},"status":{"token":"exchanged-token"}}` + "\n", wantLogs: []string{ - nowStr + ` cmd/login_oidc.go:267 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`, - nowStr + ` cmd/login_oidc.go:277 Exchanging token for cluster credential {"endpoint": "https://127.0.0.1:1234/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`, - nowStr + ` cmd/login_oidc.go:285 Successfully exchanged token for cluster credential.`, - nowStr + ` cmd/login_oidc.go:292 caching cluster credential for future use.`, + nowStr + ` cmd/login_oidc.go:268 Performing OIDC login {"issuer": "test-issuer", "client id": "test-client-id"}`, + nowStr + ` cmd/login_oidc.go:278 Exchanging token for cluster credential {"endpoint": "https://127.0.0.1:1234/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`, + nowStr + ` cmd/login_oidc.go:286 Successfully exchanged token for cluster credential.`, + nowStr + ` cmd/login_oidc.go:293 caching cluster credential for future use.`, }, }, } diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go index 9f82a3f12..cb5b2267b 100644 --- a/cmd/pinniped/cmd/login_static.go +++ b/cmd/pinniped/cmd/login_static.go @@ -113,6 +113,7 @@ func runStaticLogin(cmd *cobra.Command, deps staticLoginDeps, flags staticLoginP conciergeclient.WithBase64CABundle(flags.conciergeCABundle), conciergeclient.WithAuthenticator(flags.conciergeAuthenticatorType, flags.conciergeAuthenticatorName), conciergeclient.WithAPIGroupSuffix(flags.conciergeAPIGroupSuffix), + conciergeclient.WithTransportWrapper(LogAuditIDTransportWrapper), ) if err != nil { return fmt.Errorf("invalid Concierge parameters: %w", err) diff --git a/cmd/pinniped/cmd/login_static_test.go b/cmd/pinniped/cmd/login_static_test.go index 4663b761e..a1ed0f2d1 100644 --- a/cmd/pinniped/cmd/login_static_test.go +++ b/cmd/pinniped/cmd/login_static_test.go @@ -147,7 +147,7 @@ func TestLoginStaticCommand(t *testing.T) { Error: could not complete Concierge credential exchange: some concierge error `), wantLogs: []string{ - nowStr + ` cmd/login_static.go:159 exchanging static token for cluster credential {"endpoint": "https://127.0.0.1/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`, + nowStr + ` cmd/login_static.go:160 exchanging static token for cluster credential {"endpoint": "https://127.0.0.1/", "authenticator type": "webhook", "authenticator name": "test-authenticator"}`, }, }, { diff --git a/internal/kubeclient/roundtrip.go b/internal/kubeclient/roundtrip.go index 463d0b853..cbfd6babe 100644 --- a/internal/kubeclient/roundtrip.go +++ b/internal/kubeclient/roundtrip.go @@ -31,25 +31,25 @@ func configWithWrapper(config *restclient.Config, scheme *runtime.Scheme, negoti return config // invalid input config, will fail existing client-go validation } - // no need for any wrapping when we have no middleware to inject - if len(middlewares) == 0 { - return config + var middlewareWrapper transport.WrapperFunc + if len(middlewares) > 0 { + info, ok := runtime.SerializerInfoForMediaType(negotiatedSerializer.SupportedMediaTypes(), config.ContentType) + if !ok { + panic(fmt.Errorf("unknown content type: %s ", config.ContentType)) // static input, programmer error + } + regSerializer := info.Serializer // should perform no conversion + + resolver := server.NewRequestInfoResolver(server.NewConfig(serializer.CodecFactory{})) + + schemeRestMapperFunc := schemeRestMapper(scheme) + + middlewareWrapper = newWrapper(hostURL, apiPathPrefix, config, resolver, regSerializer, negotiatedSerializer, schemeRestMapperFunc, middlewares) } - info, ok := runtime.SerializerInfoForMediaType(negotiatedSerializer.SupportedMediaTypes(), config.ContentType) - if !ok { - panic(fmt.Errorf("unknown content type: %s ", config.ContentType)) // static input, programmer error - } - regSerializer := info.Serializer // should perform no conversion - - resolver := server.NewRequestInfoResolver(server.NewConfig(serializer.CodecFactory{})) - - schemeRestMapperFunc := schemeRestMapper(scheme) - - f := newWrapper(hostURL, apiPathPrefix, config, resolver, regSerializer, negotiatedSerializer, schemeRestMapperFunc, middlewares) - cc := restclient.CopyConfig(config) - cc.Wrap(f) + if middlewareWrapper != nil { + cc.Wrap(middlewareWrapper) + } if wrapper != nil { cc.Wrap(wrapper) } diff --git a/pkg/conciergeclient/conciergeclient.go b/pkg/conciergeclient/conciergeclient.go index 8c68c691d..740b57365 100644 --- a/pkg/conciergeclient/conciergeclient.go +++ b/pkg/conciergeclient/conciergeclient.go @@ -17,6 +17,7 @@ import ( clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/transport" authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" @@ -34,10 +35,11 @@ type Option func(*Client) error // Client is a configuration for talking to the Pinniped concierge. type Client struct { - authenticator *corev1.TypedLocalObjectReference - caBundle string - endpoint *url.URL - apiGroupSuffix string + authenticator *corev1.TypedLocalObjectReference + caBundle string + endpoint *url.URL + apiGroupSuffix string + transportWrapper transport.WrapperFunc } // WithAuthenticator configures the authenticator reference (spec.authenticator) of the TokenCredentialRequests. @@ -116,6 +118,16 @@ func WithAPIGroupSuffix(apiGroupSuffix string) Option { } } +func WithTransportWrapper(wrapper transport.WrapperFunc) Option { + return func(c *Client) error { + if wrapper == nil { + return fmt.Errorf("transport wrapper cannot be nil") + } + c.transportWrapper = wrapper + return nil + } +} + // New validates the specified options and returns a newly initialized *Client. func New(opts ...Option) (*Client, error) { c := Client{apiGroupSuffix: groupsuffix.PinnipedDefaultSuffix} @@ -158,6 +170,7 @@ func (c *Client) clientset() (conciergeclientset.Interface, error) { client, err := kubeclient.New( kubeclient.WithConfig(cfg), kubeclient.WithMiddleware(groupsuffix.New(c.apiGroupSuffix)), + kubeclient.WithTransportWrapper(c.transportWrapper), ) if err != nil { return nil, err From 8dffd60f0b493a3685550ce1dfdc5e27518a10e7 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 19 Nov 2024 12:06:39 -0600 Subject: [PATCH 56/71] Backfill unit tests for audit logging from the CLI --- pkg/oidcclient/login.go | 58 +++++++++----- pkg/oidcclient/login_test.go | 151 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 22 deletions(-) diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 27f4f7e37..0e5a78ed9 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -358,35 +358,49 @@ type nopCache struct{} func (*nopCache) GetToken(SessionCacheKey) *oidctypes.Token { return nil } func (*nopCache) PutToken(SessionCacheKey, *oidctypes.Token) {} +type auditIDLoggerFunc func(path string, statusCode int, auditID string) + +func logFailedRequest(path string, statusCode int, auditID string) { + plog.Info("Received auditID for failed request", + "path", path, + "statusCode", statusCode, + "auditID", auditID) +} + // maybePrintAuditID will choose to log the auditID when certain failure cases are detected, // to give a breadcrumb for an admin to follow. // Older Supervisors and other OIDC identity providers may not provide this header. -func maybePrintAuditID(rt http.RoundTripper) http.RoundTripper { +func maybePrintAuditID(rt http.RoundTripper, logFunc auditIDLoggerFunc) http.RoundTripper { return roundtripper.WrapFunc(rt, func(r *http.Request) (*http.Response, error) { - path := r.URL.Path response, responseErr := rt.RoundTrip(r) - if response != nil && response.Header.Get("audit-ID") != "" { - switch { - case response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest: - // failing oauth2/authorize redirects from audit-enabled Supervisors - location, err := url.Parse(response.Header.Get(httpLocationHeaderName)) - if err != nil || location.Query().Get("error") != "" { - plog.Info("Received auditID for failed request", - "path", path, - "statusCode", response.StatusCode, - "auditID", response.Header.Get("audit-ID")) - } - case response.StatusCode >= http.StatusBadRequest: - // failing discovery, oauth2/authorize, or oauth2/token responses from audit-enabled Supervisors + if response == nil || + responseErr != nil || + response.Header.Get("audit-ID") == "" || + response.Request == nil || + response.Request.URL == nil { + return response, responseErr + } - plog.Info("Received auditID for failed request", - "path", path, - "statusCode", response.StatusCode, - "auditID", response.Header.Get("audit-ID")) - default: - // noop + auditID := response.Header.Get("audit-ID") + // Use the request from the response in case other round-trippers modified the request + path := response.Request.URL.Path + + switch statusCode := response.StatusCode; { + case statusCode < http.StatusMultipleChoices: // (-inf,300) + break // noop + case response.StatusCode < http.StatusBadRequest: // [300,400) + // Rejected oauth2/authorize redirects from audit-enabled Supervisors will ALWAYS include + // the "error" parameter since it is required. + // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 for more details. + location, err := url.Parse(response.Header.Get(httpLocationHeaderName)) + if err != nil || location == nil || location.Query().Get("error") == "" { + break } + logFunc(path, statusCode, auditID) + default: // [400,inf) + // failing discovery, oauth2/authorize, or oauth2/token responses from audit-enabled Supervisors. + logFunc(path, statusCode, auditID) } return response, responseErr }) @@ -436,7 +450,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er } } - h.httpClient.Transport = maybePrintAuditID(h.httpClient.Transport) + h.httpClient.Transport = maybePrintAuditID(h.httpClient.Transport, logFailedRequest) if h.cliToSendCredentials { if h.loginFlow != "" { diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index 8f7abc98f..8ede17dc0 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -3911,3 +3911,154 @@ func TestLoggers(t *testing.T) { // NOTE: We can't really test logs with the default (e.g. no logger option specified) } + +func TestMaybePrintAuditID(t *testing.T) { + canonicalAuditIdHeaderName := "Audit-Id" + + buildResponse := func(statusCode int) *http.Response { + return &http.Response{ + Header: http.Header{ + canonicalAuditIdHeaderName: []string{"some-audit-id", "some-other-audit-id-that-will-never-be-seen"}, + }, + StatusCode: statusCode, + Request: &http.Request{ + URL: &url.URL{ + Path: "some-path-from-response-request", + }, + }, + } + } + tests := []struct { + name string + response *http.Response + responseErr error + want func(t *testing.T, called func()) auditIDLoggerFunc + wantCalled bool + }{ + { + name: "happy HTTP response - no error", + response: buildResponse(http.StatusOK), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(_ string, _ int, _ string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "HTTP response with no response.request.url will not log", + response: func() *http.Response { + response := buildResponse(http.StatusOK) + response.Request.URL = nil + return response + }(), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(_ string, _ int, _ string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "302 with error parameter in location and audit-ID will log", + response: func() *http.Response { + response := buildResponse(http.StatusFound) + response.Header.Set("Location", "https://example.com?error=some-error") + return response + }(), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + require.Equal(t, "some-path-from-response-request", path) + require.Equal(t, http.StatusFound, statusCode) + require.Equal(t, "some-audit-id", auditID) + } + }, + wantCalled: true, + }, + { + name: "303 with error parameter in location and audit-ID will log", + response: func() *http.Response { + response := buildResponse(http.StatusSeeOther) + response.Header.Set("Location", "https://example.com?error=some-error") + return response + }(), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + require.Equal(t, "some-path-from-response-request", path) + require.Equal(t, http.StatusSeeOther, statusCode) + require.Equal(t, "some-audit-id", auditID) + } + }, + wantCalled: true, + }, + { + name: "303 without error parameter in location and audit-ID will not log", + response: func() *http.Response { + response := buildResponse(http.StatusSeeOther) + response.Header.Set("Location", "https://example.com?foo=bar") + return response + }(), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "404 with error parameter in location and audit-ID will log", + response: buildResponse(http.StatusNotFound), //nolint:bodyclose // there is no Body. + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + require.Equal(t, "some-path-from-response-request", path) + require.Equal(t, http.StatusNotFound, statusCode) + require.Equal(t, "some-audit-id", auditID) + } + }, + wantCalled: true, + }, + { + name: "when the roundtrip returns an error, will not log", + responseErr: errors.New("some error"), + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotNil(t, test.want) + + mockRequest := &http.Request{ + URL: &url.URL{ + Path: "should-never-use-this-path", + }, + } + var mockRt roundtripper.Func = func(r *http.Request) (*http.Response, error) { + require.Equal(t, mockRequest, r) + return test.response, test.responseErr + } + called := false + subjectRt := maybePrintAuditID(mockRt, test.want(t, func() { + called = true + })) + actualResponse, err := subjectRt.RoundTrip(mockRequest) //nolint:bodyclose // there is no Body. + require.Equal(t, test.responseErr, err) // This roundtripper only returns mocked errors. + require.Equal(t, test.response, actualResponse) + require.Equal(t, test.wantCalled, called, "expected logFunc to be called") + }) + } +} From 51c86795af36ad01e30ab0254dcef3e44b586e4d Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 19 Nov 2024 13:29:06 -0600 Subject: [PATCH 57/71] Backfill unit tests for cmd/pinniped/cmd/audit_id.go --- cmd/pinniped/cmd/audit_id.go | 34 +++++++-- cmd/pinniped/cmd/audit_id_test.go | 116 ++++++++++++++++++++++++++++++ pkg/oidcclient/login_test.go | 3 +- 3 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 cmd/pinniped/cmd/audit_id_test.go diff --git a/cmd/pinniped/cmd/audit_id.go b/cmd/pinniped/cmd/audit_id.go index 591c7a895..51879c95a 100644 --- a/cmd/pinniped/cmd/audit_id.go +++ b/cmd/pinniped/cmd/audit_id.go @@ -10,17 +10,37 @@ import ( "go.pinniped.dev/internal/plog" ) +type auditIDLoggerFunc func(path string, statusCode int, auditID string) + +func logAuditID(path string, statusCode int, auditID string) { + plog.Info("Received auditID for failed request", + "path", path, + "statusCode", statusCode, + "auditID", auditID) +} + func LogAuditIDTransportWrapper(rt http.RoundTripper) http.RoundTripper { + return logAuditIDTransportWrapper(rt, logAuditID) +} + +func logAuditIDTransportWrapper(rt http.RoundTripper, auditIDLoggerFunc auditIDLoggerFunc) http.RoundTripper { return roundtripper.WrapFunc(rt, func(r *http.Request) (*http.Response, error) { response, responseErr := rt.RoundTrip(r) - if response != nil && response.Header.Get("audit-ID") != "" { - plog.Info("Received auditID for request", - // Use the request path from the response's request, in case the - // original request was modified by any other roudtrippers in the chain. - "path", response.Request.URL.Path, - "statusCode", response.StatusCode, - "auditID", response.Header.Get("audit-ID")) + + if responseErr != nil || + response == nil || + response.Header.Get("audit-ID") == "" || + response.Request == nil || + response.Request.URL == nil { + return response, responseErr } + + // Use the request path from the response's request, in case the + // original request was modified by any other roudtrippers in the chain. + auditIDLoggerFunc(response.Request.URL.Path, + response.StatusCode, + response.Header.Get("audit-ID")) + return response, responseErr }) } diff --git a/cmd/pinniped/cmd/audit_id_test.go b/cmd/pinniped/cmd/audit_id_test.go new file mode 100644 index 000000000..3ac73053b --- /dev/null +++ b/cmd/pinniped/cmd/audit_id_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "errors" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/httputil/roundtripper" +) + +func TestLogAuditIDTransportWrapper(t *testing.T) { + canonicalAuditIdHeaderName := "Audit-Id" + + tests := []struct { + name string + response *http.Response + responseErr error + want func(t *testing.T, called func()) auditIDLoggerFunc + wantCalled bool + }{ + { + name: "happy HTTP response - no error and no log", + response: &http.Response{ // no headers + StatusCode: http.StatusOK, + Request: &http.Request{ + URL: &url.URL{ + Path: "some-path-from-response-request", + }, + }, + }, + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(_ string, _ int, _ string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "nil HTTP response - no error and no log", + response: nil, + responseErr: nil, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(_ string, _ int, _ string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "err HTTP response - no error and no log", + response: nil, + responseErr: errors.New("some error"), + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(_ string, _ int, _ string) { + called() + } + }, + wantCalled: false, // make it obvious + }, + { + name: "happy HTTP response with audit-ID - logs", + response: &http.Response{ + Header: http.Header{ + canonicalAuditIdHeaderName: []string{"some-audit-id", "some-other-audit-id-that-will-never-be-seen"}, + }, + StatusCode: http.StatusBadGateway, // statusCode does not matter + Request: &http.Request{ + URL: &url.URL{ + Path: "some-path-from-response-request", + }, + }, + }, + want: func(t *testing.T, called func()) auditIDLoggerFunc { + return func(path string, statusCode int, auditID string) { + called() + require.Equal(t, "some-path-from-response-request", path) + require.Equal(t, http.StatusBadGateway, statusCode) + require.Equal(t, "some-audit-id", auditID) + } + }, + wantCalled: true, // make it obvious + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotNil(t, test.want) + + mockRequest := &http.Request{ + URL: &url.URL{ + Path: "should-never-use-this-path", + }, + } + var mockRt roundtripper.Func = func(r *http.Request) (*http.Response, error) { + require.Equal(t, mockRequest, r) + return test.response, test.responseErr + } + called := false + subjectRt := logAuditIDTransportWrapper(mockRt, test.want(t, func() { + called = true + })) + actualResponse, err := subjectRt.RoundTrip(mockRequest) //nolint:bodyclose // there is no Body. + require.Equal(t, test.responseErr, err) // This roundtripper only returns mocked errors. + require.Equal(t, test.response, actualResponse) + require.Equal(t, test.wantCalled, called, + "want logFunc to be called: %t, actually was called: %t", test.wantCalled, called) + }) + } +} diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index 8ede17dc0..12242011e 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -4058,7 +4058,8 @@ func TestMaybePrintAuditID(t *testing.T) { actualResponse, err := subjectRt.RoundTrip(mockRequest) //nolint:bodyclose // there is no Body. require.Equal(t, test.responseErr, err) // This roundtripper only returns mocked errors. require.Equal(t, test.response, actualResponse) - require.Equal(t, test.wantCalled, called, "expected logFunc to be called") + require.Equal(t, test.wantCalled, called, + "want logFunc to be called: %t, actually was called: %t", test.wantCalled, called) }) } } From c7e9ee1c61687af49a2a53782527248024947120 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 19 Nov 2024 14:06:34 -0600 Subject: [PATCH 58/71] Backfill unit tests for paramsSafeToLog --- .../endpoints/auth/auth_handler_test.go | 29 +++++++++++++++++++ .../callback/callback_handler_test.go | 11 +++++++ .../endpoints/login/login_handler_test.go | 9 ++++++ .../endpoints/token/token_handler.go | 5 ++-- .../endpoints/token/token_handler_test.go | 17 +++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go index 8b8350d61..5e911063f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -4431,3 +4431,32 @@ func requireEqualURLsIgnoringState(t *testing.T, actualURL string, expectedURL s require.Equal(t, expectedLocationQuery, actualLocationQuery) } + +// TestParamsSafeToLog only exists to ensure that paramsSafeToLog will not be accidentally updated. +func TestParamsSafeToLog(t *testing.T) { + wantParams := []string{ + "access_type", + "acr_values", + "claims", + "claims_locales", + "client_id", + "code_challenge_method", + "display", + "id_token_hint", + "login_hint", + "max_age", + "pinniped_idp_name", + "pinniped_idp_type", + "prompt", + "redirect_uri", + "registration", + "request", + "request_uri", + "response_mode", + "response_type", + "scope", + "ui_locales", + } + + require.ElementsMatch(t, wantParams, paramsSafeToLog().UnsortedList()) +} diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index d20a157c4..6dd419553 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -2206,3 +2206,14 @@ func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string } return copied } + +// TestParamsSafeToLog only exists to ensure that paramsSafeToLog will not be accidentally updated. +func TestParamsSafeToLog(t *testing.T) { + wantParams := []string{ + "error", + "error_description", + "error_uri", + } + + require.ElementsMatch(t, wantParams, paramsSafeToLog().UnsortedList()) +} diff --git a/internal/federationdomain/endpoints/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go index 75fa5cce9..243f5da14 100644 --- a/internal/federationdomain/endpoints/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -553,3 +553,12 @@ func (r *requestPath) String() string { } return path + params.Encode() } + +// TestParamsSafeToLog only exists to ensure that paramsSafeToLog will not be accidentally updated. +func TestParamsSafeToLog(t *testing.T) { + wantParams := []string{ + "err", + } + + require.ElementsMatch(t, wantParams, paramsSafeToLog().UnsortedList()) +} diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index c09925797..86909c9c9 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -36,10 +36,11 @@ func paramsSafeToLog() sets.Set[string] { // Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants. // Redacting code, client_secret, refresh_token, and PKCE code_verifier params. "grant_type", "client_id", "redirect_uri", "scope", - // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693. + // Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693#section-2.1. // Redact subject_token and actor_token. // We don't allow all of these, but they should be safe to log. - "audience", "resource", "scope", "requested_token_type", "actor_token_type", "subject_token_type", + // "scope" is already included from the authcode grant. + "audience", "resource", "requested_token_type", "actor_token_type", "subject_token_type", ) } diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 9f0773fc3..2c5174790 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -5996,3 +5996,20 @@ func getSecretNameFromSignature(t *testing.T, signature string, typeLabel string signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes)) return fmt.Sprintf("pinniped-storage-%s-%s", typeLabel, signatureAsValidName) } + +// TestParamsSafeToLog only exists to ensure that paramsSafeToLog will not be accidentally updated. +func TestParamsSafeToLog(t *testing.T) { + wantParams := []string{ + "actor_token_type", + "audience", + "client_id", + "grant_type", + "redirect_uri", + "requested_token_type", + "resource", + "scope", + "subject_token_type", + } + + require.ElementsMatch(t, wantParams, paramsSafeToLog().UnsortedList()) +} From 1ebe2fcd1a95e4dfa80de3105c2d37b5c0a41618 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 19 Nov 2024 13:42:55 -0800 Subject: [PATCH 59/71] add integration test for personal info showing in login audit logs --- test/integration/audit_test.go | 440 +++++++++++++++--- .../integration/limited_ciphers_utils_test.go | 40 +- 2 files changed, 415 insertions(+), 65 deletions(-) diff --git a/test/integration/audit_test.go b/test/integration/audit_test.go index da429d2ec..598b0db15 100644 --- a/test/integration/audit_test.go +++ b/test/integration/audit_test.go @@ -6,9 +6,14 @@ package integration import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "net/http" + "os" + "os/exec" + "path/filepath" + "slices" "strings" "testing" "time" @@ -19,10 +24,15 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes" "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" + authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/config/concierge" + "go.pinniped.dev/internal/config/supervisor" "go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/test/testlib" ) @@ -41,6 +51,321 @@ func kubeClientWithoutPinnipedAPISuffix(t *testing.T) kubernetes.Interface { return client.Kubernetes } +// TestAuditLogsDuringLogin is an end-to-end login test which cares more about making audit log +// assertions than assertions about the login itself. Much of how this test performs a login was +// inspired by a test case from TestE2EFullIntegration_Browser. This test is Disruptive because +// it restarts the Supervisor and Concierge to reconfigure audit logging, and then restarts them +// again to put back the original configuration. +func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { + env := testEnvForPodShutdownTests(t) + + testStartTime := metav1.Now() + + ctx, cancelFunc := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancelFunc() + + kubeClient := testlib.NewKubernetesClientset(t) + kubeClientForK8sResourcesOnly := kubeClientWithoutPinnipedAPISuffix(t) + + // Build pinniped CLI. + pinnipedExe := testlib.PinnipedCLIPath(t) + + supervisorIssuer := env.InferSupervisorIssuerURL(t) + + // Generate a CA bundle with which to serve this provider. + t.Logf("generating test CA") + tlsServingCertForSupervisorSecretName := "federation-domain-serving-cert-" + testlib.RandHex(t, 8) + + federationDomainSelfSignedCA := createTLSServingCertSecretForSupervisor( + ctx, + t, + env, + supervisorIssuer, + tlsServingCertForSupervisorSecretName, + kubeClient, + ) + + // Save that bundle plus the one that signs the upstream issuer, for test purposes. + federationDomainCABundlePath := filepath.Join(t.TempDir(), "test-ca.pem") + federationDomainCABundlePEM := federationDomainSelfSignedCA.Bundle() + require.NoError(t, os.WriteFile(federationDomainCABundlePath, federationDomainCABundlePEM, 0600)) + + // Create the downstream FederationDomain. + // This helper function will nil out spec.TLS if spec.Issuer is an IP address. + federationDomain := testlib.CreateTestFederationDomain(ctx, t, + supervisorconfigv1alpha1.FederationDomainSpec{ + Issuer: supervisorIssuer.Issuer(), + TLS: &supervisorconfigv1alpha1.FederationDomainTLSSpec{SecretName: tlsServingCertForSupervisorSecretName}, + }, + supervisorconfigv1alpha1.FederationDomainPhaseError, // in phase error until there is an IDP created + ) + + expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + expectedGroups := make([]any, len(env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs)) + for i, g := range env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs { + expectedGroups[i] = g + } + + // Create a JWTAuthenticator that will validate the tokens from the downstream issuer. + // If the FederationDomain is not Ready, the JWTAuthenticator cannot be ready, either. + clusterAudience := "test-cluster-" + testlib.RandHex(t, 8) + defaultJWTAuthenticatorSpec := authenticationv1alpha1.JWTAuthenticatorSpec{ + Issuer: federationDomain.Spec.Issuer, + Audience: clusterAudience, + TLS: &authenticationv1alpha1.TLSSpec{CertificateAuthorityData: base64.StdEncoding.EncodeToString(federationDomainCABundlePEM)}, + } + authenticator := testlib.CreateTestJWTAuthenticator(ctx, t, defaultJWTAuthenticatorSpec, authenticationv1alpha1.JWTAuthenticatorPhaseError) + setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(ctx, t, federationDomain.Name, supervisorconfigv1alpha1.FederationDomainPhaseReady) + testlib.WaitForJWTAuthenticatorStatusPhase(ctx, t, authenticator.Name, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + + tempDir := t.TempDir() // per-test tmp dir to avoid sharing files between tests + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" + + kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, + // use default for --oidc-scopes, which is to request all relevant scopes + }) + + t.Setenv("PINNIPED_USERNAME", expectedUsername) + t.Setenv("PINNIPED_PASSWORD", env.SupervisorUpstreamLDAP.TestUserPassword) + + timeBeforeLogin := metav1.Now() + + // Run kubectl command which should run an LDAP-style login without interactive prompts for username and password. + kubectlCmd := exec.CommandContext(ctx, "kubectl", "auth", "whoami", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv()) + kubectlOutput, err := kubectlCmd.CombinedOutput() + require.NoErrorf(t, err, + "expected no error but got error, combined stdout/stderr was:\n----start of output\n%s\n----end of output", kubectlOutput) + + allSupervisorSessionStartedLogs := getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["message"] == string(auditevent.SessionStarted) + }, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + timeBeforeLogin, + ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorSessionStartedLogs) + // Also remove sessionID, which is a UUID that we can't predict for the assertions below. + for _, log := range allSupervisorSessionStartedLogs { + require.NotEmpty(t, log["sessionID"]) + delete(log, "sessionID") + } + + // All values in the personalInfo map should be redacted by default. + require.Equal(t, []map[string]any{ + { + "message": "Session Started", + "personalInfo": map[string]any{ + "username": "redacted", + "groups": []any{"redacted 2 values"}, + "subject": "redacted", + "additionalClaims": map[string]any{"redacted": "redacted 0 keys"}, + }, + "warnings": []any{}, + }, + }, allSupervisorSessionStartedLogs) + + allConciergeTCRLogs := getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["message"] == string(auditevent.TokenCredentialRequestAuthenticatedUser) + }, + kubeClientForK8sResourcesOnly, + env.ConciergeNamespace, + env.ConciergeAppName, + timeBeforeLogin, + ) + removeSomeKeysFromEachAuditLogEvent(allConciergeTCRLogs) + // Also remove issuedClientCertExpires, which is a timestamp that we can't easily predict for the assertions below. + for _, log := range allConciergeTCRLogs { + require.NotEmpty(t, log["issuedClientCertExpires"]) + delete(log, "issuedClientCertExpires") + } + + // All values in the personalInfo map should be redacted by default. + require.Equal(t, []map[string]any{ + { + "message": "TokenCredentialRequest Authenticated User", + "authenticator": map[string]any{ + // this always pinniped.dev even when the API group suffix was customized because of the way that the production code works + "apiGroup": "authentication.concierge.pinniped.dev", + "kind": "JWTAuthenticator", + "name": authenticator.Name, + }, + "personalInfo": map[string]any{ + "username": "redacted", + "groups": []any{"redacted 2 values"}, + }, + }, + }, allConciergeTCRLogs) + + allSupervisorHealthzLogs := getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["path"] == "/healthz" + }, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + testStartTime, + ) + // There should be none, because /healthz audit logs are disabled by default. + require.Empty(t, allSupervisorHealthzLogs) + + t.Log("updating Supervisor's static ConfigMap and restarting the pods") + updateStaticConfigMapAndRestartApp(t, + ctx, + env.SupervisorNamespace, + env.SupervisorAppName+"-static-config", + env.SupervisorAppName, + false, + func(t *testing.T, configMapData string) string { + t.Helper() + + var config supervisor.Config + err := yaml.Unmarshal([]byte(configMapData), &config) + require.NoError(t, err) + + // The Supervisor has two audit configuration options. Enable both. + config.Audit.LogUsernamesAndGroups = "enabled" + config.Audit.LogInternalPaths = "enabled" + + updatedConfig, err := yaml.Marshal(config) + require.NoError(t, err) + return string(updatedConfig) + }, + ) + + t.Log("updating Concierge's static ConfigMap and restarting the pods") + updateStaticConfigMapAndRestartApp(t, + ctx, + env.ConciergeNamespace, + env.ConciergeAppName+"-config", + env.ConciergeAppName, + true, + func(t *testing.T, configMapData string) string { + t.Helper() + + var config concierge.Config + err := yaml.Unmarshal([]byte(configMapData), &config) + require.NoError(t, err) + + // The Concierge has only one audit configuration option. Enable it. + config.Audit.LogUsernamesAndGroups = "enabled" + + updatedConfig, err := yaml.Marshal(config) + require.NoError(t, err) + return string(updatedConfig) + }, + ) + + // Force a fresh login for the next kubectl command by removing the local caches. + require.NoError(t, os.Remove(sessionCachePath)) + require.NoError(t, os.Remove(credentialCachePath)) + + // Reset the start time before we do a second login. + timeBeforeLogin = metav1.Now() + + // Do a second login, which should cause audit logs with non-redacted personal info. + // Run kubectl command which should run an LDAP-style login without interactive prompts for username and password. + kubectlCmd = exec.CommandContext(ctx, "kubectl", "auth", "whoami", "--kubeconfig", kubeconfigPath) + kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv()) + kubectlOutput, err = kubectlCmd.CombinedOutput() + require.NoErrorf(t, err, + "expected no error but got error, combined stdout/stderr was:\n----start of output\n%s\n----end of output", kubectlOutput) + + allSupervisorSessionStartedLogs = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["message"] == string(auditevent.SessionStarted) + }, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + timeBeforeLogin, + ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorSessionStartedLogs) + // Also remove sessionID, which is a UUID that we can't predict for the assertions below. + for _, log := range allSupervisorSessionStartedLogs { + require.NotEmpty(t, log["sessionID"]) + delete(log, "sessionID") + } + // Now that "subject" should not be redacted, remove it too because it also contains values that are hard to predict. + for _, log := range allSupervisorSessionStartedLogs { + p := log["personalInfo"].(map[string]any) + require.NotEmpty(t, p) + require.Contains(t, p["subject"], "ldaps://"+env.SupervisorUpstreamLDAP.Host+"?") + delete(p, "subject") + } + + // All values in the personalInfo map should not be redacted anymore. + require.Equal(t, []map[string]any{ + { + "message": "Session Started", + "personalInfo": map[string]any{ + "username": expectedUsername, + "groups": expectedGroups, + // note that we removed "subject" above + "additionalClaims": map[string]any{}, + }, + "warnings": []any{}, + }, + }, allSupervisorSessionStartedLogs) + + allConciergeTCRLogs = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["message"] == string(auditevent.TokenCredentialRequestAuthenticatedUser) + }, + kubeClientForK8sResourcesOnly, + env.ConciergeNamespace, + env.ConciergeAppName, + timeBeforeLogin, + ) + removeSomeKeysFromEachAuditLogEvent(allConciergeTCRLogs) + // Also remove issuedClientCertExpires, which is a timestamp that we can't easily predict for the assertions below. + for _, log := range allConciergeTCRLogs { + require.NotEmpty(t, log["issuedClientCertExpires"]) + delete(log, "issuedClientCertExpires") + } + + // All values in the personalInfo map should not be redacted anymore. + require.Equal(t, []map[string]any{ + { + "message": "TokenCredentialRequest Authenticated User", + "authenticator": map[string]any{ + "apiGroup": "authentication.concierge." + env.APIGroupSuffix, + "kind": "JWTAuthenticator", + "name": authenticator.Name, + }, + "personalInfo": map[string]any{ + "username": expectedUsername, + "groups": expectedGroups, + }, + }, + }, allConciergeTCRLogs) + + allSupervisorHealthzLogs = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["path"] == "/healthz" + }, + kubeClientForK8sResourcesOnly, + env.SupervisorNamespace, + env.SupervisorAppName, + testStartTime, + ) + // There should be some, because we reconfigured the setting to enable them. + t.Logf("saw %d audit logs where path=/healthz in Supervisor pod logs", len(allSupervisorHealthzLogs)) + require.NotEmpty(t, allSupervisorHealthzLogs) +} + func TestAuditLogsEmittedForDiscoveryEndpoints_Parallel(t *testing.T) { ctx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Minute) defer cancelFunc() @@ -49,23 +374,23 @@ func TestAuditLogsEmittedForDiscoveryEndpoints_Parallel(t *testing.T) { startTime := metav1.Now() //nolint:bodyclose // this is closed in the helper function - _, _, auditID := requireSuccessEndpointResponse( - t, + _, _, auditID := requireSuccessEndpointResponse(t, fakeIssuerForDisplayPurposes.Issuer()+"/.well-known/openid-configuration", fakeIssuerForDisplayPurposes.Issuer(), ca.Bundle(), dnsOverrides, ) - allSupervisorPodLogsWithAuditID := getAuditLogsForAuditID( - t, - ctx, - auditID, + allSupervisorPodLogsWithAuditID := getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["auditID"] == auditID + }, kubeClientForK8sResourcesOnly, env.SupervisorNamespace, env.SupervisorAppName, startTime, ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorPodLogsWithAuditID) require.Equal(t, 2, len(allSupervisorPodLogsWithAuditID), "expected exactly two log lines with auditID=%s", auditID) @@ -100,8 +425,7 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test // Call the /oauth2/authorize endpoint startTime := metav1.Now() //nolint:bodyclose // this is closed in the helper function - _, _, auditID := requireEndpointResponse( - t, + _, _, auditID := requireEndpointResponse(t, fakeIssuerForDisplayPurposes.Issuer()+"/oauth2/authorize?foo=bar&foo=bar&scope=safe-to-log", fakeIssuerForDisplayPurposes.Issuer(), ca.Bundle(), @@ -109,15 +433,16 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test http.StatusBadRequest, ) - allSupervisorPodLogsWithAuditID := getAuditLogsForAuditID( - t, - ctx, - auditID, + allSupervisorPodLogsWithAuditID := getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["auditID"] == auditID + }, kubeClientForK8sResourcesOnly, env.SupervisorNamespace, env.SupervisorAppName, startTime, ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorPodLogsWithAuditID) require.Equal(t, []map[string]any{ { @@ -154,8 +479,7 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test // Call the /callback endpoint startTime = metav1.Now() //nolint:bodyclose // this is closed in the helper function - _, _, auditID = requireEndpointResponse( - t, + _, _, auditID = requireEndpointResponse(t, fakeIssuerForDisplayPurposes.Issuer()+"/callback?foo=bar&foo=bar&error=safe-to-log", fakeIssuerForDisplayPurposes.Issuer(), ca.Bundle(), @@ -163,15 +487,16 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test http.StatusForbidden, ) - allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( - t, - ctx, - auditID, + allSupervisorPodLogsWithAuditID = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["auditID"] == auditID + }, kubeClientForK8sResourcesOnly, env.SupervisorNamespace, env.SupervisorAppName, startTime, ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorPodLogsWithAuditID) require.Equal(t, []map[string]any{ { @@ -203,8 +528,7 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test // Call the /login endpoint startTime = metav1.Now() //nolint:bodyclose // this is closed in the helper function - _, _, auditID = requireEndpointResponse( - t, + _, _, auditID = requireEndpointResponse(t, fakeIssuerForDisplayPurposes.Issuer()+"/login?foo=bar&foo=bar&err=safe-to-log", fakeIssuerForDisplayPurposes.Issuer(), ca.Bundle(), @@ -212,15 +536,16 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test http.StatusForbidden, ) - allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( - t, - ctx, - auditID, + allSupervisorPodLogsWithAuditID = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["auditID"] == auditID + }, kubeClientForK8sResourcesOnly, env.SupervisorNamespace, env.SupervisorAppName, startTime, ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorPodLogsWithAuditID) require.Equal(t, []map[string]any{ { @@ -252,8 +577,7 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test // Call the /oauth2/token endpoint startTime = metav1.Now() //nolint:bodyclose // this is closed in the helper function - _, _, auditID = requireEndpointResponse( - t, + _, _, auditID = requireEndpointResponse(t, fakeIssuerForDisplayPurposes.Issuer()+"/oauth2/token?foo=bar&foo=bar&grant_type=safe-to-log", fakeIssuerForDisplayPurposes.Issuer(), ca.Bundle(), @@ -261,15 +585,16 @@ func TestAuditLogsEmittedForEndpointsEvenWhenTheCallsAreInvalid_Parallel(t *test http.StatusBadRequest, ) - allSupervisorPodLogsWithAuditID = getAuditLogsForAuditID( - t, - ctx, - auditID, + allSupervisorPodLogsWithAuditID = getFilteredAuditLogs(t, ctx, + func(log map[string]any) bool { + return log["auditID"] == auditID + }, kubeClientForK8sResourcesOnly, env.SupervisorNamespace, env.SupervisorAppName, startTime, ) + removeSomeKeysFromEachAuditLogEvent(allSupervisorPodLogsWithAuditID) require.Equal(t, []map[string]any{ { @@ -363,24 +688,23 @@ func auditSetup(t *testing.T, ctx context.Context) ( return env, kubeClientForK8sResourcesOnly, fakeIssuerForDisplayPurposes, ca, dnsOverrides } -func cleanupAuditLog(t *testing.T, m *map[string]any, auditID string) { - delete(*m, "caller") - delete(*m, "remoteAddr") - delete(*m, "userAgent") - delete(*m, "timestamp") - delete(*m, "latency") - require.Equal(t, (*m)["level"], "info") - delete(*m, "level") - require.Equal(t, (*m)["auditEvent"], true) - delete(*m, "auditEvent") - require.Equal(t, (*m)["auditID"], auditID) - delete(*m, "auditID") +func removeSomeKeysFromEachAuditLogEvent(logs []map[string]any) { + for _, log := range logs { + delete(log, "level") + delete(log, "auditEvent") + delete(log, "caller") + delete(log, "remoteAddr") + delete(log, "userAgent") + delete(log, "timestamp") + delete(log, "latency") + delete(log, "auditID") + } } -func getAuditLogsForAuditID( +func getFilteredAuditLogs( t *testing.T, ctx context.Context, - auditID string, + filterAuditLogEvent func(log map[string]any) bool, kubeClient kubernetes.Interface, namespace string, appName string, @@ -392,9 +716,7 @@ func getAuditLogsForAuditID( defer cancel() pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.Set{ - "app": appName, - }.String(), + LabelSelector: labels.Set{"app": appName}.String(), }) require.NoError(t, err) @@ -405,19 +727,25 @@ func getAuditLogsForAuditID( } allPodLogs := strings.Split(allPodLogsBuffer.String(), "\n") - var allPodLogsWithAuditID []map[string]any + var filteredAuditLogs []map[string]any for _, podLog := range allPodLogs { - if strings.Contains(podLog, auditID) { - var deserialized map[string]any - err = json.Unmarshal([]byte(podLog), &deserialized) - require.NoError(t, err) - cleanupAuditLog(t, &deserialized, auditID) - - allPodLogsWithAuditID = append(allPodLogsWithAuditID, deserialized) + if len(podLog) == 0 { + continue + } + var deserializedPodLog map[string]any + err = json.Unmarshal([]byte(podLog), &deserializedPodLog) + require.NoErrorf(t, err, "error parsing line of pod log: %s", podLog) + isAuditEventBool, hasAuditEvent := deserializedPodLog["auditEvent"] + if hasAuditEvent { + require.Equal(t, true, isAuditEventBool) + require.Equal(t, "info", deserializedPodLog["level"]) + } + if hasAuditEvent && filterAuditLogEvent(deserializedPodLog) { + filteredAuditLogs = append(filteredAuditLogs, deserializedPodLog) } } - return allPodLogsWithAuditID + return filteredAuditLogs } func getLogsForPodSince( diff --git a/test/integration/limited_ciphers_utils_test.go b/test/integration/limited_ciphers_utils_test.go index f2eef58cf..10b79ce8d 100644 --- a/test/integration/limited_ciphers_utils_test.go +++ b/test/integration/limited_ciphers_utils_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" @@ -173,7 +174,6 @@ func updateStaticConfigMapAndRestartApp( } // restartAllPodsOfApp will immediately scale to 0 and then scale back. -// There are no uses of t.Cleanup since these actions need to happen immediately. func restartAllPodsOfApp( t *testing.T, namespace string, @@ -195,17 +195,39 @@ func restartAllPodsOfApp( originalScale := updateDeploymentScale(t, namespace, appName, 0) require.Greater(t, int(originalScale), 0) + scaleDeploymentBackToOriginalScale := func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + client := testlib.NewKubernetesClientset(t) + + currentScale, err := client.AppsV1().Deployments(namespace).GetScale(ctx, appName, metav1.GetOptions{}) + require.NoError(t, err) + if currentScale.Spec.Replicas == originalScale { + // Already scaled appropriately. No need to change the scale. + return + } + + updateDeploymentScale(t, namespace, appName, originalScale) + + // Wait for all the new pods to be running and ready. + var newPods []corev1.Pod + testlib.RequireEventually(t, func(requireEventually *require.Assertions) { + newPods = getRunningPodsByNamePrefix(t, namespace, appName+"-", ignorePodsWithNameSubstring) + requireEventually.Equal(len(newPods), int(originalScale), "wanted pods to return to original scale") + requireEventually.True(allPodsReady(newPods), "wanted all new pods to be ready") + }, 2*time.Minute, 200*time.Millisecond) + } + + // Even if the test fails due to the below assertions, still try to scale back to original scale, + // to avoid polluting other tests. + t.Cleanup(scaleDeploymentBackToOriginalScale) + + // Now that we have adjusted the scale to 0, the pods should go away. testlib.RequireEventually(t, func(requireEventually *require.Assertions) { newPods := getRunningPodsByNamePrefix(t, namespace, appName+"-", ignorePodsWithNameSubstring) requireEventually.Len(newPods, 0, "wanted zero pods") }, 2*time.Minute, 200*time.Millisecond) - // Reset the application to its original scale. - updateDeploymentScale(t, namespace, appName, originalScale) - - testlib.RequireEventually(t, func(requireEventually *require.Assertions) { - newPods := getRunningPodsByNamePrefix(t, namespace, appName+"-", ignorePodsWithNameSubstring) - requireEventually.Equal(len(newPods), int(originalScale), "wanted %d pods", originalScale) - requireEventually.True(allPodsReady(newPods), "wanted all new pods to be ready") - }, 2*time.Minute, 200*time.Millisecond) + // Scale back to original scale immediately. + scaleDeploymentBackToOriginalScale() } From ce2dcbdbb3f39fbf8c9a60ed903cb6a52734b8ef Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 19 Nov 2024 16:46:04 -0600 Subject: [PATCH 60/71] simplify godoc --- .../endpoints/auth/auth_handler.go | 17 +---------------- .../requestlogger/request_logger.go | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/internal/federationdomain/endpoints/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go index a0b2684c2..ec211c51f 100644 --- a/internal/federationdomain/endpoints/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -214,22 +214,7 @@ func (h *authorizeHandler) authorize( } if err != nil { // No specific audit event is emitted here in the case of an authorization error. - // There are currently seven possible cases: - // (1) OIDC with cli_password: - // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. - // - There's no way to determine why the OIDC provider rejected the request. - // (2) OIDC with browser_authcode: this endpoint only redirects upstream - // (3) LDAP with cli_password: - // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. - // - If we know that the LDAP provider rejected the request due to incorrect username or password, - // Pinniped will provide the "Incorrect Username Or Password" audit event. - // (4) LDAP with browser_authcode: this endpoint only redirects to the /login page - // (5) Active Directory with cli_password: - // - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. - // - If we know that the Active Directory provider rejected the request due to incorrect username or password, - // Pinniped will provide the "Incorrect Username Or Password" audit event. - // (6) Active Directory with browser_authcode: this endpoint only redirects to the /login page - // (7) GitHub with browser_authcode (cli_password is not supported): this endpoint only redirects upstream + // Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong. oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow) } } diff --git a/internal/federationdomain/requestlogger/request_logger.go b/internal/federationdomain/requestlogger/request_logger.go index 1560379a9..34ada1034 100644 --- a/internal/federationdomain/requestlogger/request_logger.go +++ b/internal/federationdomain/requestlogger/request_logger.go @@ -85,7 +85,7 @@ func (rl *requestLogger) logRequestReceived() { KeysAndValues: []any{ "proto", r.Proto, "method", r.Method, - "host", r.Host, + "host", r.Host, // The "Host" header is promoted to this field. "serverName", requestutil.SNIServerName(r), "path", r.URL.Path, "userAgent", rl.userAgent, From 0a28c818adb6f1455fe10c9435259f3357d7350e Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Tue, 19 Nov 2024 21:17:30 -0600 Subject: [PATCH 61/71] Small fixes for integration tests --- test/integration/audit_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/integration/audit_test.go b/test/integration/audit_test.go index 598b0db15..976142371 100644 --- a/test/integration/audit_test.go +++ b/test/integration/audit_test.go @@ -124,7 +124,7 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { sessionCachePath := tempDir + "/test-sessions.yaml" credentialCachePath := tempDir + "/test-credentials.yaml" - kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + pinnipedStyleKubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", "--concierge-api-group-suffix", env.APIGroupSuffix, "--concierge-authenticator-type", "jwt", @@ -140,7 +140,11 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { timeBeforeLogin := metav1.Now() // Run kubectl command which should run an LDAP-style login without interactive prompts for username and password. - kubectlCmd := exec.CommandContext(ctx, "kubectl", "auth", "whoami", "--kubeconfig", kubeconfigPath) + // We'd prefer to use "kubectl auth whoami" but that's only available in recent K8s. + // Generally on a kind cluster there is a clusterrolebinding "system:basic-user" and a clusterrole "system:basic-user" + // that allows those in group "system:authenticated" to call this API, so it does prove that we authenticated. + kubectlCmd := exec.CommandContext(ctx, "kubectl", "auth", "can-i", "create", "selfsubjectaccessreviews", + "--kubeconfig", pinnipedStyleKubeconfigPath) kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv()) kubectlOutput, err := kubectlCmd.CombinedOutput() require.NoErrorf(t, err, @@ -197,7 +201,7 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { { "message": "TokenCredentialRequest Authenticated User", "authenticator": map[string]any{ - // this always pinniped.dev even when the API group suffix was customized because of the way that the production code works + // this is always pinniped.dev even when the API group suffix was customized because of the way that the production code works "apiGroup": "authentication.concierge.pinniped.dev", "kind": "JWTAuthenticator", "name": authenticator.Name, @@ -277,7 +281,11 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { // Do a second login, which should cause audit logs with non-redacted personal info. // Run kubectl command which should run an LDAP-style login without interactive prompts for username and password. - kubectlCmd = exec.CommandContext(ctx, "kubectl", "auth", "whoami", "--kubeconfig", kubeconfigPath) + // We'd prefer to use "kubectl auth whoami" but that's only available in recent K8s. + // Generally on a kind cluster there is a clusterrolebinding "system:basic-user" and a clusterrole "system:basic-user" + // that allows those in group "system:authenticated" to call this API, so it does prove that we authenticated. + kubectlCmd = exec.CommandContext(ctx, "kubectl", "auth", "can-i", "create", "selfsubjectaccessreviews", + "--kubeconfig", pinnipedStyleKubeconfigPath) kubectlCmd.Env = slices.Concat(os.Environ(), env.ProxyEnv()) kubectlOutput, err = kubectlCmd.CombinedOutput() require.NoErrorf(t, err, @@ -341,7 +349,8 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { { "message": "TokenCredentialRequest Authenticated User", "authenticator": map[string]any{ - "apiGroup": "authentication.concierge." + env.APIGroupSuffix, + // this is always pinniped.dev even when the API group suffix was customized because of the way that the production code works + "apiGroup": "authentication.concierge.pinniped.dev", "kind": "JWTAuthenticator", "name": authenticator.Name, }, From bc73505e356cf603c387a8ee88bb98d8a0662ee0 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 20 Nov 2024 09:55:24 -0600 Subject: [PATCH 62/71] Easily enable kind audit logs with ENABLE_AUDIT_LOGGING=true ./hack/kind-up.sh --- hack/kind-up.sh | 6 +- .../kind-config/metadata-audit-policy.yaml | 4 + hack/lib/kind-config/single-node.yaml | 96 ++++++++++++------- 3 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 hack/lib/kind-config/metadata-audit-policy.yaml diff --git a/hack/kind-up.sh b/hack/kind-up.sh index 7fdb90d03..b0fce803c 100755 --- a/hack/kind-up.sh +++ b/hack/kind-up.sh @@ -37,8 +37,12 @@ if [[ "${PINNIPED_USE_LOCAL_KIND_REGISTRY:-}" != "" ]]; then use_kind_registry="--file=${ROOT}/hack/lib/kind-config/kind-registry-overlay.yaml" fi +cp "${ROOT}/hack/lib/kind-config/metadata-audit-policy.yaml" /tmp/metadata-audit-policy.yaml + # Do not quote ${use_kind_registry} ${use_contour_registry} in this command because they might be empty. -ytt ${use_kind_registry} ${use_contour_registry} --file="${ROOT}"/hack/lib/kind-config/single-node.yaml >/tmp/kind-config.yaml +ytt ${use_kind_registry} ${use_contour_registry} \ + --data-value-yaml enable_audit_logs=${ENABLE_KIND_AUDIT_LOGS:-false} \ + --file="${ROOT}"/hack/lib/kind-config/single-node.yaml >/tmp/kind-config.yaml # To choose a specific version of kube, add this option to the command below: `--image kindest/node:v1.28.0`. # To use the "latest-main" version of kubernetes builds by the pipeline, use `--image ghcr.io/pinniped-ci-bot/kind-node-image:latest` diff --git a/hack/lib/kind-config/metadata-audit-policy.yaml b/hack/lib/kind-config/metadata-audit-policy.yaml new file mode 100644 index 000000000..67ec4611e --- /dev/null +++ b/hack/lib/kind-config/metadata-audit-policy.yaml @@ -0,0 +1,4 @@ +apiVersion: audit.k8s.io/v1 +kind: Policy +rules: +- level: Metadata diff --git a/hack/lib/kind-config/single-node.yaml b/hack/lib/kind-config/single-node.yaml index f71785087..71202f09c 100644 --- a/hack/lib/kind-config/single-node.yaml +++ b/hack/lib/kind-config/single-node.yaml @@ -1,46 +1,76 @@ +#@ load("@ytt:data", "data") + kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane extraPortMappings: - protocol: TCP - # This same port number is hardcoded in the integration test setup - # when creating a Service on a kind cluster. It is used to talk to - # the supervisor app via HTTPS. + #! This same port number is hardcoded in the integration test setup + #! when creating a Service on a kind cluster. It is used to talk to + #! the supervisor app via HTTPS. containerPort: 31243 hostPort: 12344 listenAddress: 127.0.0.1 - protocol: TCP - # This same port number is hardcoded in the integration test setup - # when creating a Service on a kind cluster. It is used to talk to - # the Dex app. + #! This same port number is hardcoded in the integration test setup + #! when creating a Service on a kind cluster. It is used to talk to + #! the Dex app. containerPort: 31235 hostPort: 12346 listenAddress: 127.0.0.1 -# Kind v0.12.0 ignores kubeadm.k8s.io/v1beta2 for Kube v1.23+ but uses it for older versions of Kube. -# Previous versions of Kind would use kubeadm.k8s.io/v1beta2 for all versions of Kube including 1.23. -# To try to maximize compatibility with various versions of Kind and Kube, define this -# ClusterConfiguration twice and hope that Kind will use the one that it likes for the given version -# of Kube, and ignore the one that it doesn't like. This seems to work, at least for Kind v0.12.0. -kubeadmConfigPatches: -- | - apiVersion: kubeadm.k8s.io/v1beta2 - kind: ClusterConfiguration - apiServer: - extraArgs: - # To make sure the endpoints on our service are correct (this mostly matters for kubectl based - # installs where kapp is not doing magic changes to the deployment and service selectors). - # Setting this field to true makes it so that the API service will do the service cluster IP - # to endpoint IP translations internally instead of relying on the network stack (i.e. kube-proxy). - # The logic inside the API server is very straightforward - randomly pick an IP from the list - # of available endpoints. This means that over time, all endpoints associated with the service - # are exercised. For whatever reason, leaving this as false (i.e. use kube-proxy) appears to - # hide some network misconfigurations when used internally by the API server aggregation layer. - enable-aggregator-routing: "true" -- | - apiVersion: kubeadm.k8s.io/v1beta3 - kind: ClusterConfiguration - apiServer: - extraArgs: - # See comment above. - enable-aggregator-routing: "true" + + + #! Kind v0.12.0 ignores kubeadm.k8s.io/v1beta2 for Kube v1.23+ but uses it for older versions of Kube. + #! Previous versions of Kind would use kubeadm.k8s.io/v1beta2 for all versions of Kube including 1.23. + #! To try to maximize compatibility with various versions of Kind and Kube, define this + #! ClusterConfiguration twice and hope that Kind will use the one that it likes for the given version + #! of Kube, and ignore the one that it doesn't like. This seems to work, at least for Kind v0.12.0. + kubeadmConfigPatches: + - | + apiVersion: kubeadm.k8s.io/v1beta2 + kind: ClusterConfiguration + apiServer: + extraArgs: + #! To make sure the endpoints on our service are correct (this mostly matters for kubectl based + #! installs where kapp is not doing magic changes to the deployment and service selectors). + #! Setting this field to true makes it so that the API service will do the service cluster IP + #! to endpoint IP translations internally instead of relying on the network stack (i.e. kube-proxy). + #! The logic inside the API server is very straightforward - randomly pick an IP from the list + #! of available endpoints. This means that over time, all endpoints associated with the service + #! are exercised. For whatever reason, leaving this as false (i.e. use kube-proxy) appears to + #! hide some network misconfigurations when used internally by the API server aggregation layer. + enable-aggregator-routing: "true" + - | + apiVersion: kubeadm.k8s.io/v1beta3 + kind: ClusterConfiguration + apiServer: + extraArgs: + # See comment above. + enable-aggregator-routing: "true" + #@ if data.values.enable_audit_logs: + - | + kind: ClusterConfiguration + apiServer: + #! enable auditing flags on the API server + extraArgs: + audit-log-path: /var/log/kubernetes/kube-apiserver-audit.log + audit-policy-file: /etc/kubernetes/policies/audit-policy.yaml + #! mount new files / directories on the control plane + extraVolumes: + - name: audit-policies + hostPath: /etc/kubernetes/policies + mountPath: /etc/kubernetes/policies + readOnly: true + pathType: "DirectoryOrCreate" + - name: "audit-logs" + hostPath: "/var/log/kubernetes" + mountPath: "/var/log/kubernetes" + readOnly: false + pathType: DirectoryOrCreate + #! mount the local file on the control plane + extraMounts: + - hostPath: /tmp/metadata-audit-policy.yaml + containerPath: /etc/kubernetes/policies/audit-policy.yaml + readOnly: true + #@ end From c803a182be6c92b635cb859c3cc5a680e55074d1 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Wed, 20 Nov 2024 12:25:34 -0600 Subject: [PATCH 63/71] Allow override of audit.log_usernames_and_groups for local debugging --- hack/prepare-for-integration-tests.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 0f8f7c045..8dc3f2a28 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -317,6 +317,8 @@ custom_labels: $supervisor_custom_labels service_https_nodeport_port: $service_https_nodeport_port service_https_nodeport_nodeport: $service_https_nodeport_nodeport service_https_clusterip_port: $service_https_clusterip_port +audit: + log_usernames_and_groups: ${LOG_USERNAMES_AND_GROUPS:-disabled} EOF if [[ "${FIREWALL_IDPS:-no}" == "yes" ]]; then @@ -361,6 +363,8 @@ custom_labels: $concierge_custom_labels image_repo: $registry_repo image_tag: $tag discovery_url: $discovery_url +audit: + log_usernames_and_groups: ${LOG_USERNAMES_AND_GROUPS:-disabled} EOF if [[ "${FIREWALL_IDPS:-no}" == "yes" ]]; then From 4423d472dacceb483dfbfba8f8e2a4a46db392b8 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 20 Nov 2024 13:22:31 -0800 Subject: [PATCH 64/71] allow audit correlation between token being issued and being used --- internal/auditevent/audit_event.go | 51 ++++-- .../endpoints/token/token_handler.go | 52 +++++++ .../endpoints/token/token_handler_test.go | 147 +++++++++++++----- internal/registry/credentialrequest/rest.go | 9 ++ .../registry/credentialrequest/rest_test.go | 41 +++++ site/content/docs/reference/audit-logging.md | 3 + 6 files changed, 244 insertions(+), 59 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index 363b06bea..0a0960360 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -6,25 +6,42 @@ package auditevent type Message string const ( - HTTPRequestReceived Message = "HTTP Request Received" - HTTPRequestCompleted Message = "HTTP Request Completed" - HTTPRequestParameters Message = "HTTP Request Parameters" - HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" - UsingUpstreamIDP Message = "Using Upstream IDP" - AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" - IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" - IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" - SessionStarted Message = "Session Started" - SessionRefreshed Message = "Session Refreshed" - SessionFound Message = "Session Found" - AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" - UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential - SessionGarbageCollected Message = "Session Garbage Collected" - UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" - OIDCClientSecretRequestUpdatedSecrets Message = "OIDCClientSecretRequest Updated Secrets" + // Supervisor request logging. + + HTTPRequestReceived Message = "HTTP Request Received" + HTTPRequestCompleted Message = "HTTP Request Completed" + HTTPRequestParameters Message = "HTTP Request Parameters" + HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" + HTTPRequestBasicAuthUsed Message = "HTTP Request Basic Auth" + + // Supervisor authentication logging. + + UsingUpstreamIDP Message = "Using Upstream IDP" + AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" + IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" + UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" + IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" + IDTokenIssued Message = "ID Token Issued" //nolint:gosec // this is not a credential + SessionStarted Message = "Session Started" + SessionRefreshed Message = "Session Refreshed" + SessionFound Message = "Session Found" + AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" + IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" + + // Supervisor session ending logging. + + UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential + SessionGarbageCollected Message = "Session Garbage Collected" + + // Supervisor aggregated APIs logging. + + OIDCClientSecretRequestUpdatedSecrets Message = "OIDCClientSecretRequest Updated Secrets" + + // Concierge aggregated APIs logging. + + TokenCredentialRequestTokenReceived Message = "TokenCredentialRequest Token Received" //nolint:gosec // this is not a credential TokenCredentialRequestAuthenticatedUser Message = "TokenCredentialRequest Authenticated User" //nolint:gosec // this is not a credential TokenCredentialRequestAuthenticationFailed Message = "TokenCredentialRequest Authentication Failed" //nolint:gosec // this is not a credential TokenCredentialRequestUnexpectedError Message = "TokenCredentialRequest Unexpected Error" //nolint:gosec // this is not a credential TokenCredentialRequestUnsupportedUserInfo Message = "TokenCredentialRequest Unsupported UserInfo" //nolint:gosec // this is not a credential - IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" //nolint:gosec // this is not a credential ) diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 86909c9c9..7dd0ef053 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -6,6 +6,7 @@ package token import ( "context" + "crypto/sha256" "errors" "fmt" "net/http" @@ -56,6 +57,7 @@ func NewHandler( oauthHelper.WriteAccessError(r.Context(), w, nil, err) return nil } + auditLogBasicAuthClientID(r, auditLogger) session := psession.NewPinnipedSession() accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session) @@ -114,6 +116,9 @@ func NewHandler( return nil } + // Allow cross-referencing the token with the Concierge's audit logs. + auditLogIDToken(r.Context(), auditLogger, accessRequest, accessResponse) + oauthHelper.WriteAccessResponse(r.Context(), w, accessRequest, accessResponse) return nil @@ -391,3 +396,50 @@ func diffSortedGroups(oldGroups, newGroups []string) ([]string, []string) { removed := oldGroupsAsSet.Difference(newGroupsAsSet) // groups in oldGroups that are not in newGroups i.e. removed return added.List(), removed.List() } + +func auditLogBasicAuthClientID(r *http.Request, auditLogger plog.AuditLogger) { + // For dynamic clients, the client ID is from basic auth, not from the request parameters. + clientIDFromBasicAuth, _, basicAuthUsed := r.BasicAuth() + if basicAuthUsed { + auditLogger.Audit(auditevent.HTTPRequestBasicAuthUsed, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{"clientID", clientIDFromBasicAuth}, + }) + } +} + +func auditLogIDToken( + reqCtx context.Context, + auditLogger plog.AuditLogger, + accessRequest fosite.AccessRequester, + accessResponse fosite.AccessResponder, +) { + var idToken string + + if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange) { + // Token exchanges return the ID token in the access token field of the response. + idToken = accessResponse.GetAccessToken() + } else { + // For other grant types, there may not be an access token, e.g. when the openid scope was not granted. + tok := accessResponse.GetExtra("id_token") + if tok != nil { + // This should always be a string. Checking just to be safe. + tokAsStr, ok := tok.(string) + if ok { + idToken = tokAsStr + } + } + } + + if len(idToken) == 0 { + return + } + + auditLogger.Audit(auditevent.IDTokenIssued, &plog.AuditParams{ + ReqCtx: reqCtx, + Session: accessRequest, + KeysAndValues: []any{ + "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))), + }, + }) +} diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 2c5174790..343da2035 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -312,7 +312,7 @@ type tokenEndpointResponseExpectedValues struct { // The expected lifetime of the ID tokens issued by authcode exchange and refresh, but not token exchange. // When zero, will assume that the test wants the default value for ID token lifetime. wantIDTokenLifetimeSeconds int - wantAuditLogs func(sessionID string) []testutil.WantedAuditLog + wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog } func withWantCustomIDTokenLifetime(wantIDTokenLifetimeSeconds int, w tokenEndpointResponseExpectedValues) tokenEndpointResponseExpectedValues { @@ -368,6 +368,10 @@ func addDynamicClientIDToFormPostBody(r *http.Request) { r.Form.Set("client_id", dynamicClientID) } +func idTokenToHash(tok string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(tok))) +} + func TestTokenEndpointAuthcodeExchange(t *testing.T) { tests := []struct { name string @@ -387,7 +391,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -399,6 +403,10 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -458,7 +466,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -468,7 +476,12 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -549,7 +562,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -583,6 +596,21 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, + }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + // Note that there was no ID token issued, so there is no "ID Token Issued" audit log. + } + }, }, }, }, @@ -955,7 +983,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -981,7 +1009,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1144,7 +1172,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus int wantErrorType string wantErrorDescContains string - wantAuditLogs func(sessionID string) []testutil.WantedAuditLog + wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog }{ { name: "happy path", @@ -1188,7 +1216,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "name": "value", }, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1200,13 +1228,17 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, }, requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1219,6 +1251,10 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -1394,7 +1430,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "unauthorized_client", wantErrorDescContains: `The client is not authorized to request a token using this method. The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'.`, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1405,6 +1441,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", }, }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), } }, }, @@ -1505,7 +1542,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "invalid_request", wantErrorDescContains: "Missing 'audience' parameter.", - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1784,7 +1821,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn t.Parallel() // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - subject, rsp, _, _, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, + subject, rsp, _, _, secrets, oauthStore, actualAuditLog, actualSessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, testidplister.NewUpstreamIDPListerBuilder().BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -1824,12 +1861,6 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") - if test.wantAuditLogs != nil { - wantAuditLogs := test.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } - var parsedResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -1845,6 +1876,13 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.NotEmpty(t, errorDesc) require.Contains(t, errorDesc, test.wantErrorDescContains) + // Even in the error case, make assertions about audit logs, but without an ID token. + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, "") + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } + // The remaining assertions apply only to the happy path. return } @@ -1860,7 +1898,8 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Equal(t, "urn:ietf:params:oauth:token-type:jwt", parsedResponseBody["issued_token_type"]) // Parse the returned token. - parsedJWT, err := jose.ParseSigned(parsedResponseBody["access_token"].(string), []jose.SignatureAlgorithm{jose.ES256}) + actualIDToken := parsedResponseBody["access_token"].(string) + parsedJWT, err := jose.ParseSigned(actualIDToken, []jose.SignatureAlgorithm{jose.ES256}) require.NoError(t, err) var tokenClaims map[string]any require.NoError(t, json.Unmarshal(parsedJWT.UnsafePayloadWithoutVerification(), &tokenClaims)) @@ -1948,6 +1987,12 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn newSecrets, err := secrets.List(context.Background(), metav1.ListOptions{}) require.NoError(t, err) require.ElementsMatch(t, existingSecrets.Items, newSecrets.Items) + + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, actualIDToken) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } }) } } @@ -2184,7 +2229,7 @@ func TestRefreshGrant(t *testing.T) { return want } - refreshResponseWithAuditLogs := func(expectedValues tokenEndpointResponseExpectedValues, wantAuditLogs func(sessionID string) []testutil.WantedAuditLog) tokenEndpointResponseExpectedValues { + refreshResponseWithAuditLogs := func(expectedValues tokenEndpointResponseExpectedValues, wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog) tokenEndpointResponseExpectedValues { expectedValues.wantAuditLogs = wantAuditLogs return expectedValues } @@ -2338,7 +2383,7 @@ func TestRefreshGrant(t *testing.T) { upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), refreshedUpstreamTokensWithIDAndRefreshTokens(), ), - func(sessionID string) []testutil.WantedAuditLog { + func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2367,6 +2412,10 @@ func TestRefreshGrant(t *testing.T) { "subject": "https://issuer?sub=some-subject", }, }), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, ), @@ -2536,7 +2585,7 @@ func TestRefreshGrant(t *testing.T) { "error_description": "Error during upstream refresh. Upstream refresh rejected by configured identity policy: authentication was rejected by a configured policy." } `), - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2607,7 +2656,7 @@ func TestRefreshGrant(t *testing.T) { "name": "value", }, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2619,6 +2668,10 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -2973,7 +3026,7 @@ func TestRefreshGrant(t *testing.T) { {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -3007,6 +3060,10 @@ func TestRefreshGrant(t *testing.T) { "subject": "https://issuer?sub=some-subject", }, }), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -4955,7 +5012,7 @@ func TestRefreshGrant(t *testing.T) { // First exchange the authcode for tokens, including a refresh token. // It's actually fine to use this function even when simulating LDAP (which uses a different flow) because it's // just populating a secret in storage. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, actualSessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -4999,12 +5056,6 @@ func TestRefreshGrant(t *testing.T) { t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) - if test.refreshRequest.want.wantAuditLogs != nil { - wantAuditLogs := test.refreshRequest.want.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-refresh-grant-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } - // Test that we did or did not make a call to the upstream provider's interface to perform refresh. switch { case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil: @@ -5067,6 +5118,9 @@ func TestRefreshGrant(t *testing.T) { jwtSigningKey, secrets, approxRequestTime, + actualSessionID, + "fake-refresh-grant-audit-id", + actualAuditLog, ) if test.refreshRequest.want.wantStatus == http.StatusOK { @@ -5141,7 +5195,7 @@ func exchangeAuthcodeForTokens( secrets v1.SecretInterface, oauthStore *storage.KubeStorage, actualAuditLog *bytes.Buffer, - sessionID string, + actualSessionID string, ) { authRequest := deepCopyRequestForm(happyAuthRequest) if test.modifyAuthRequest != nil { @@ -5206,13 +5260,7 @@ func exchangeAuthcodeForTokens( t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) - sessionID = getSessionID(t, secrets) - - if test.want.wantAuditLogs != nil { - wantAuditLogs := test.want.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-code-grant-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } + actualSessionID = getSessionID(t, secrets) wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) @@ -5226,9 +5274,12 @@ func exchangeAuthcodeForTokens( jwtSigningKey, secrets, approxRequestTime, + actualSessionID, + "fake-code-grant-audit-id", + actualAuditLog, ) - return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID + return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, actualSessionID } func getSessionID(t *testing.T, secrets v1.SecretInterface) string { @@ -5255,10 +5306,14 @@ func requireTokenEndpointBehavior( jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, requestTime time.Time, + actualSessionID string, + wantAuditID string, + actualAuditLog *bytes.Buffer, ) { testutil.RequireEqualContentType(t, tokenEndpointResponse.Header().Get("Content-Type"), "application/json") require.Equal(t, test.wantStatus, tokenEndpointResponse.Code) + var actualIDToken string if test.wantStatus == http.StatusOK { require.NotNil(t, test.wantSuccessBodyFields, "problem with test table setup: wanted success but did not specify expected response body") @@ -5279,7 +5334,7 @@ func requireTokenEndpointBehavior( expectedNumberOfRefreshTokenSessionsStored = 1 } if wantIDToken { - requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantNonceValueInIDToken, test.wantUsername, test.wantGroups, test.wantAdditionalClaims, test.wantIDTokenLifetimeSeconds, parsedResponseBody["access_token"].(string), requestTime) + actualIDToken = requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantNonceValueInIDToken, test.wantUsername, test.wantGroups, test.wantAdditionalClaims, test.wantIDTokenLifetimeSeconds, parsedResponseBody["access_token"].(string), requestTime) } if wantRefreshToken { requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantUsername, test.wantGroups, test.wantCustomSessionDataStored, test.wantAdditionalClaims, secrets, requestTime) @@ -5297,6 +5352,12 @@ func requireTokenEndpointBehavior( require.JSONEq(t, test.wantErrorResponseBody, tokenEndpointResponse.Body.String()) } + + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, actualIDToken) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, wantAuditID) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } } func hashAccessToken(accessToken string) string { @@ -5806,7 +5867,7 @@ func requireValidIDToken( wantIDTokenLifetimeSeconds int, actualAccessToken string, requestTime time.Time, -) { +) string { t.Helper() idToken, ok := body["id_token"] @@ -5891,6 +5952,8 @@ func requireValidIDToken( require.NotEmpty(t, actualAccessToken) require.Equal(t, hashAccessToken(actualAccessToken), claims.AccessTokenHash) + + return idTokenString } func deepCopyRequestForm(r *http.Request) *http.Request { diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index cf987f770..a05da3189 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -6,6 +6,7 @@ package credentialrequest import ( "context" + "crypto/sha256" "errors" "fmt" "time" @@ -112,6 +113,14 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return nil, err } + // Allow cross-referencing the token with the Supervisor's audit logs. + r.auditLogger.Audit(auditevent.TokenCredentialRequestTokenReceived, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(credentialRequest.Spec.Token))), + }, + }) + userInfo, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest) if err != nil { r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{ diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 564bbf7eb..b970a6c25 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -6,6 +6,7 @@ package credentialrequest import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "testing" @@ -67,6 +68,10 @@ func TestNew(t *testing.T) { require.Error(t, err, "the resource panda.bears does not support being converted to a Table") } +func tokenToHash(tok string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(tok))) +} + func TestCreate(t *testing.T) { spec.Run(t, "create", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions @@ -125,6 +130,10 @@ func TestCreate(t *testing.T) { }) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -162,6 +171,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -188,6 +201,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authentication Failed", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -214,6 +231,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -241,6 +262,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -277,6 +302,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -313,6 +342,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -389,6 +422,10 @@ func TestCreate(t *testing.T) { r.NotEmpty(response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -436,6 +473,10 @@ func TestCreate(t *testing.T) { r.Empty(validationFunctionSawTokenValue) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index e1acbe0c6..6fc74b00c 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -92,6 +92,9 @@ correlate an audit event log line to other logs. The values for these keys are o - When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated across some of the browser redirects which relate to a single login attempt by an end user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. +- When applicable, audit logs have a `tokenIdentifier` which is a unique ID of a token to allow audit events to be correlated + between where a token is issued to an end user in the Supervisor and where a token is used to gain access to a + Kubernetes cluster in the Concierge. Each audit event may also have more key-value pairs specific to the event's type. From dfe04c5a582d4532c4c49e11b536ebe4820e9d86 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 10:29:15 -0800 Subject: [PATCH 65/71] update audit-logging.md to reflect changes in recent commits --- site/content/docs/reference/audit-logging.md | 247 ++++++++++++++----- 1 file changed, 192 insertions(+), 55 deletions(-) diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index 6fc74b00c..ce97b2a0f 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -166,24 +166,24 @@ The logs from the authorize endpoint are shown below. ```json lines { "level": "info", - "timestamp": "2024-11-14T18:41:53.162801Z", + "timestamp": "2024-11-21T17:48:43.566433Z", "message": "HTTP Request Received", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", "proto": "HTTP/2.0", "method": "GET", "host": "example-supervisor.pinniped.dev", "serverName": "example-supervisor.pinniped.dev", "path": "/oauth2/authorize", - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", - "remoteAddr": "1.2.3.4:40262" + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "remoteAddr": "1.2.3.4:58586" } { "level": "info", - "timestamp": "2024-11-14T18:41:53.162877Z", + "timestamp": "2024-11-21T17:48:43.566519Z", "message": "HTTP Request Parameters", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", "params": { "access_type": "offline", "client_id": "pinniped-cli", @@ -192,7 +192,7 @@ The logs from the authorize endpoint are shown below. "nonce": "redacted", "pinniped_idp_name": "My OIDC IDP", "pinniped_idp_type": "oidc", - "redirect_uri": "http://127.0.0.1:55186/callback", + "redirect_uri": "http://127.0.0.1:55379/callback", "response_mode": "form_post", "response_type": "code", "scope": "groups offline_access openid pinniped:request-audience username", @@ -201,40 +201,40 @@ The logs from the authorize endpoint are shown below. } { "level": "info", - "timestamp": "2024-11-14T18:41:53.163006Z", + "timestamp": "2024-11-21T17:48:43.567086Z", "message": "HTTP Request Custom Headers Used", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", "Pinniped-Username": false, "Pinniped-Password": false } { "level": "info", - "timestamp": "2024-11-14T18:41:53.163056Z", + "timestamp": "2024-11-21T17:48:43.567133Z", "message": "Using Upstream IDP", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", "displayName": "My OIDC IDP", "resourceName": "my-oidc-provider", - "resourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f", + "resourceUID": "754c1c2f-84a4-4e79-981c-8d8ff9da42df", "type": "oidc" } { "level": "info", - "timestamp": "2024-11-14T18:41:53.163433Z", + "timestamp": "2024-11-21T17:48:43.567548Z", "message": "Upstream Authorize Redirect", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", - "authorizeID": "8129f3052a512881c72a329bb3044b8f39b7e9ed30e28f91b04d3917570b80e8" + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", + "authorizeID": "fe25634e5094b7f74e4666166f1520436d95bbeeea5109744ca5ad163217a08b" } { "level": "info", - "timestamp": "2024-11-14T18:41:53.163464Z", + "timestamp": "2024-11-21T17:48:43.567576Z", "message": "HTTP Request Completed", "auditEvent": true, - "auditID": "29826e50-4668-4bca-b905-a6a2d1aacd3c", + "auditID": "2d979b88-0e1e-46d4-8c64-44a0bfa1af17", "path": "/oauth2/authorize", - "latency": "671.792µs", + "latency": "1.173084ms", "responseStatus": 303, "location": "https://example-external-oidc.pinniped.dev/auth?client_id=redacted&code_challenge=redacted&code_challenge_method=redacted&nonce=redacted&redirect_uri=redacted&response_type=redacted&scope=redacted&state=redacted" } @@ -248,24 +248,24 @@ with the logs from this callback request, shown below. ```json lines { "level": "info", - "timestamp": "2024-11-14T18:42:11.887705Z", + "timestamp": "2024-11-21T17:49:07.764567Z", "message": "HTTP Request Received", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", "proto": "HTTP/2.0", "method": "GET", "host": "example-supervisor.pinniped.dev", "serverName": "example-supervisor.pinniped.dev", "path": "/callback", - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15", - "remoteAddr": "1.2.3.4:40262" + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15", + "remoteAddr": "1.2.3.4:58586" } { "level": "info", - "timestamp": "2024-11-14T18:42:11.887769Z", + "timestamp": "2024-11-21T17:49:07.764626Z", "message": "HTTP Request Parameters", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", "params": { "code": "redacted", "state": "redacted" @@ -273,29 +273,29 @@ with the logs from this callback request, shown below. } { "level": "info", - "timestamp": "2024-11-14T18:42:11.887853Z", + "timestamp": "2024-11-21T17:49:07.764707Z", "message": "AuthorizeID From Parameters", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", - "authorizeID": "8129f3052a512881c72a329bb3044b8f39b7e9ed30e28f91b04d3917570b80e8" + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", + "authorizeID": "fe25634e5094b7f74e4666166f1520436d95bbeeea5109744ca5ad163217a08b" } { "level": "info", - "timestamp": "2024-11-14T18:42:11.887872Z", + "timestamp": "2024-11-21T17:49:07.764734Z", "message": "Using Upstream IDP", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", "displayName": "My OIDC IDP", "resourceName": "my-oidc-provider", - "resourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f", + "resourceUID": "754c1c2f-84a4-4e79-981c-8d8ff9da42df", "type": "oidc" } { "level": "info", - "timestamp": "2024-11-14T18:42:11.899166Z", + "timestamp": "2024-11-21T17:49:07.775753Z", "message": "Identity From Upstream IDP", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", "personalInfo": { "upstreamUsername": "pinny@example.com", "upstreamGroups": ["developers", "auditors"] @@ -303,15 +303,15 @@ with the logs from this callback request, shown below. "upstreamIDPDisplayName": "My OIDC IDP", "upstreamIDPType": "oidc", "upstreamIDPResourceName": "my-oidc-provider", - "upstreamIDPResourceUID": "1028052a-4061-473b-b54a-0f6d4c15651f" + "upstreamIDPResourceUID": "754c1c2f-84a4-4e79-981c-8d8ff9da42df" } { "level": "info", - "timestamp": "2024-11-14T18:42:11.899243Z", + "timestamp": "2024-11-21T17:49:07.775859Z", "message": "Session Started", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", - "sessionID": "22a0fe9f-9cab-4248-8dac-bff71291b95c", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", + "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87", "personalInfo": { "username": "pinny@example.com", "groups": ["developers", "auditors"], @@ -322,12 +322,12 @@ with the logs from this callback request, shown below. } { "level": "info", - "timestamp": "2024-11-14T18:42:11.909870Z", + "timestamp": "2024-11-21T17:49:07.786155Z", "message": "HTTP Request Completed", "auditEvent": true, - "auditID": "6d8c2f3f-7556-48fe-b5fb-b4fc4cae38a7", + "auditID": "1697bdfd-ccdc-4f22-9f30-9b9b8acf964a", "path": "/callback", - "latency": "22.183042ms", + "latency": "21.603667ms", "responseStatus": 200, "location": "no location header" } @@ -344,52 +344,189 @@ The logs from the token endpoint are shown below. ```json lines { "level": "info", - "timestamp": "2024-11-14T18:42:15.190376Z", + "timestamp": "2024-11-21T17:49:11.359739Z", "message": "HTTP Request Received", "auditEvent": true, - "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", "proto": "HTTP/2.0", "method": "POST", "host": "example-supervisor.pinniped.dev", "serverName": "example-supervisor.pinniped.dev", "path": "/oauth2/token", "userAgent": "pinniped/v0.0.0 (darwin/arm64) kubernetes/$Format", - "remoteAddr": "1.2.3.4:42446" + "remoteAddr": "1.2.3.4:59420" } { "level": "info", - "timestamp": "2024-11-14T18:42:15.190475Z", + "timestamp": "2024-11-21T17:49:11.359905Z", "message": "HTTP Request Parameters", "auditEvent": true, - "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", "params": { "code": "redacted", "code_verifier": "redacted", "grant_type": "authorization_code", - "redirect_uri": "http://127.0.0.1:55186/callback" + "redirect_uri": "http://127.0.0.1:55379/callback" } } { "level": "info", - "timestamp": "2024-11-14T18:42:15.190479Z", - "message": "Session Found", + "timestamp": "2024-11-21T17:49:11.359954Z", + "message": "HTTP Request Basic Auth", "auditEvent": true, - "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", - "sessionID": "22a0fe9f-9cab-4248-8dac-bff71291b95c" + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", + "clientID": "pinniped-cli" } { "level": "info", - "timestamp": "2024-11-14T18:42:15.396784Z", + "timestamp": "2024-11-21T17:49:11.372646Z", + "message": "Session Found", + "auditEvent": true, + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", + "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.576172Z", + "message": "ID Token Issued", + "auditEvent": true, + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", + "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87", + "tokenIdentifier": "255b785220fe841e950aaf2f78df167991f2b38d2f0b25cc4449301e91d63913" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.576319Z", "message": "HTTP Request Completed", "auditEvent": true, - "auditID": "6dd829ce-9060-4062-ab8d-2053cb1eef70", + "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", "path": "/oauth2/token", - "latency": "206.434458ms", + "latency": "216.627292ms", "responseStatus": 200, "location": "no location header" } ``` -In a typical login flow, several more endpoints are called, but we omit them here for brevity. As we've seen, -a user's entire authentication journey can be followed by using the `auditID`, `authorizeID`, and `sessionID` -correlation values to find related audit log events. +Next, the token endpoint is called again to request a new ID token with reduced scope which will only work +for the target workload cluster (technically, an ID token with a different `aud` claim). These logs are shown below. + +```json lines +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.585635Z", + "message": "HTTP Request Received", + "auditEvent": true, + "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", + "proto": "HTTP/2.0", + "method": "POST", + "host": "example-supervisor.pinniped.dev", + "serverName": "example-supervisor.pinniped.dev", + "path": "/oauth2/token", + "userAgent": "pinniped/v0.0.0 (darwin/arm64) kubernetes/$Format", + "remoteAddr": "1.2.3.4:59420" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.585748Z", + "message": "HTTP Request Parameters", + "auditEvent": true, + "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", + "params": { + "audience": "my-workload-cluster-1f4757da", + "client_id": "pinniped-cli", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:jwt", + "subject_token": "redacted", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token" + } +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.766796Z", + "message": "Session Found", + "auditEvent": true, + "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", + "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.767113Z", + "message": "ID Token Issued", + "auditEvent": true, + "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", + "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87", + "tokenIdentifier": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.767198Z", + "message": "HTTP Request Completed", + "auditEvent": true, + "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", + "path": "/oauth2/token", + "latency": "181.197416ms", + "responseStatus": 200, + "location": "no location header" +} +``` + +Note that when the ID token is issued, it prints a `tokenIdentifier` which is a unique identifier for that +specific token. Technically, it is a sha256sum of the token. This can be used to cross-reference the usage +of this specific token to other systems. + +Finally, that ID token is submitted to the workload cluster's Concierge to get a temporary credential which +grants access to that workload cluster. In those logs below, you can see how the `tokenIdentifier` can be used +to follow the user's session to another cluster by following the token. This `TokenCredentialRequest` endpoint +s a Kubernetes API, so the `auditID` value from the Concierge pod logs will match the `auditID` value in +the Kubernetes audit logs, allowing them to be correlated. + +```json lines +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.783402Z", + "message": "TokenCredentialRequest Token Received", + "auditEvent": true, + "auditID": "6776ad70-b587-4bfd-ae41-74ab5e3e00f5", + "tokenIdentifier": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" +} +{ + "level": "info", + "timestamp": "2024-11-21T17:49:11.786405Z", + "message": "TokenCredentialRequest Authenticated User", + "auditEvent": true, + "auditID": "6776ad70-b587-4bfd-ae41-74ab5e3e00f5", + "personalInfo": { + "username": "pinny@example.com", + "groups": ["developers", "auditors"] + }, + "issuedClientCertExpires": "2024-11-21T17:54:11Z", + "authenticator": { + "apiGroup": "authentication.concierge.pinniped.dev", + "kind": "JWTAuthenticator", + "name": "my-jwt-authenticator" + } +} +``` + +As we've seen, a user's entire authentication journey across clusters can be followed by using the +`auditID`, `authorizeID`, `sessionID`, and `tokenIdentifier` correlation values to find related audit log events. + +## Watching the audit logs + +Here is a handy command to watch the audit logs from a Supervisor's pod logs which pretty-prints the logs and +removes keys to make them more terse. A similar command would work for the Concierge's pod logs. + +```shell +kubectl logs --follow --selector=app=pinniped-supervisor -n pinniped-supervisor \ + | jq --unbuffered -r '. | select(.auditEvent == true) | del(.caller) | del(.level) | del(.auditEvent)' +``` + +## End users getting auditIDs + +The `auditID` of each request is returned on an HTTP response header to clients. + +If an end user encounters an authentication problem, they can get the `auditID` of the failed request to share +with their Pinniped administrator, who can then search the pod logs to find the audit logs associated with that +particular request. This may aid in debugging the problem. The end user can set the environment variable +`PINNIPED_DEBUG=true` while using `kubectl` and other similar tools with their Pinniped-compatible kubeconfig. +The extra console output caused by that environment variable will include the `auditID` of any failed requests. From 54b35c30daf52a42b43f861086abcd83b1835d82 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 10:38:16 -0800 Subject: [PATCH 66/71] rename `tokenIdentifier` to `tokenID` in the audit logs Because `tokenID` is more consistent with the names of the other correlation keys. --- .../endpoints/token/token_handler.go | 2 +- .../endpoints/token/token_handler_test.go | 28 +++++++-------- internal/registry/credentialrequest/rest.go | 2 +- .../registry/credentialrequest/rest_test.go | 36 +++++++++---------- site/content/docs/reference/audit-logging.md | 14 ++++---- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 7dd0ef053..278a94cb5 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -439,7 +439,7 @@ func auditLogIDToken( ReqCtx: reqCtx, Session: accessRequest, KeysAndValues: []any{ - "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))), + "tokenID", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))), }, }) } diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 343da2035..b6bc43217 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -404,8 +404,8 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -479,8 +479,8 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -1229,8 +1229,8 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -1252,8 +1252,8 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -2413,8 +2413,8 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -2669,8 +2669,8 @@ func TestRefreshGrant(t *testing.T) { }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, @@ -3061,8 +3061,8 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("ID Token Issued", map[string]any{ - "sessionID": sessionID, - "tokenIdentifier": idTokenToHash(idToken), + "sessionID": sessionID, + "tokenID": idTokenToHash(idToken), }), } }, diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index a05da3189..a8ef1c318 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -117,7 +117,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation r.auditLogger.Audit(auditevent.TokenCredentialRequestTokenReceived, &plog.AuditParams{ ReqCtx: ctx, KeysAndValues: []any{ - "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(credentialRequest.Spec.Token))), + "tokenID", fmt.Sprintf("%x", sha256.Sum256([]byte(credentialRequest.Spec.Token))), }, }) diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index b970a6c25..854746738 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -131,8 +131,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", @@ -172,8 +172,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", @@ -202,8 +202,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Authentication Failed", map[string]any{ "auditID": "fake-audit-id", @@ -232,8 +232,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", @@ -263,8 +263,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", @@ -303,8 +303,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", @@ -343,8 +343,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", @@ -423,8 +423,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", @@ -474,8 +474,8 @@ func TestCreate(t *testing.T) { wantAuditLog = []testutil.WantedAuditLog{ testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ - "auditID": "fake-audit-id", - "tokenIdentifier": tokenToHash(req.Spec.Token), + "auditID": "fake-audit-id", + "tokenID": tokenToHash(req.Spec.Token), }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index ce97b2a0f..e39796283 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -92,7 +92,7 @@ correlate an audit event log line to other logs. The values for these keys are o - When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated across some of the browser redirects which relate to a single login attempt by an end user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. -- When applicable, audit logs have a `tokenIdentifier` which is a unique ID of a token to allow audit events to be correlated +- When applicable, audit logs have a `tokenID` which is a unique ID of a token to allow audit events to be correlated between where a token is issued to an end user in the Supervisor and where a token is used to gain access to a Kubernetes cluster in the Concierge. @@ -392,7 +392,7 @@ The logs from the token endpoint are shown below. "auditEvent": true, "auditID": "4effaac3-3f56-4133-9fa8-15104a3022c9", "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87", - "tokenIdentifier": "255b785220fe841e950aaf2f78df167991f2b38d2f0b25cc4449301e91d63913" + "tokenID": "255b785220fe841e950aaf2f78df167991f2b38d2f0b25cc4449301e91d63913" } { "level": "info", @@ -455,7 +455,7 @@ for the target workload cluster (technically, an ID token with a different `aud` "auditEvent": true, "auditID": "b49b0a29-b1af-4902-a4fc-bea2c851fcb6", "sessionID": "316fa17f-2ea3-47fd-b7b0-2b02097d8c87", - "tokenIdentifier": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" + "tokenID": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" } { "level": "info", @@ -470,12 +470,12 @@ for the target workload cluster (technically, an ID token with a different `aud` } ``` -Note that when the ID token is issued, it prints a `tokenIdentifier` which is a unique identifier for that +Note that when the ID token is issued, it prints a `tokenID` which is a unique identifier for that specific token. Technically, it is a sha256sum of the token. This can be used to cross-reference the usage of this specific token to other systems. Finally, that ID token is submitted to the workload cluster's Concierge to get a temporary credential which -grants access to that workload cluster. In those logs below, you can see how the `tokenIdentifier` can be used +grants access to that workload cluster. In those logs below, you can see how the `tokenID` can be used to follow the user's session to another cluster by following the token. This `TokenCredentialRequest` endpoint s a Kubernetes API, so the `auditID` value from the Concierge pod logs will match the `auditID` value in the Kubernetes audit logs, allowing them to be correlated. @@ -487,7 +487,7 @@ the Kubernetes audit logs, allowing them to be correlated. "message": "TokenCredentialRequest Token Received", "auditEvent": true, "auditID": "6776ad70-b587-4bfd-ae41-74ab5e3e00f5", - "tokenIdentifier": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" + "tokenID": "931aabb59f2ecedb1ae9ed1d3c94dd37d169aecce5cbd3dd2096295d3b409720" } { "level": "info", @@ -509,7 +509,7 @@ the Kubernetes audit logs, allowing them to be correlated. ``` As we've seen, a user's entire authentication journey across clusters can be followed by using the -`auditID`, `authorizeID`, `sessionID`, and `tokenIdentifier` correlation values to find related audit log events. +`auditID`, `authorizeID`, `sessionID`, and `tokenID` correlation values to find related audit log events. ## Watching the audit logs From 51ae78213558c0f4f977a2a925db2568dd0f87f2 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 11:02:45 -0800 Subject: [PATCH 67/71] fix typo in audit-logging.md --- site/content/docs/reference/audit-logging.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index e39796283..93763d5d4 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -477,8 +477,8 @@ of this specific token to other systems. Finally, that ID token is submitted to the workload cluster's Concierge to get a temporary credential which grants access to that workload cluster. In those logs below, you can see how the `tokenID` can be used to follow the user's session to another cluster by following the token. This `TokenCredentialRequest` endpoint -s a Kubernetes API, so the `auditID` value from the Concierge pod logs will match the `auditID` value in -the Kubernetes audit logs, allowing them to be correlated. +is a Kubernetes API, so the `auditID` value from the Concierge pod logs will match the `auditID` value in +the Kubernetes audit logs for the same request, allowing them to be correlated. ```json lines { @@ -510,6 +510,8 @@ the Kubernetes audit logs, allowing them to be correlated. As we've seen, a user's entire authentication journey across clusters can be followed by using the `auditID`, `authorizeID`, `sessionID`, and `tokenID` correlation values to find related audit log events. +The same correlation values could be used to trace a user's journey both forwards and backwards in time +through the logs. ## Watching the audit logs From ecd23e86cea1d7dbcd69f8626767689e94eb9afa Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 13:01:32 -0800 Subject: [PATCH 68/71] callback endpoint renders more useful user-facing error messages Co-authored-by: Joshua Casey --- .../endpoints/callback/callback_handler.go | 40 ++++++++++++- .../callback/callback_handler_test.go | 56 ++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/internal/federationdomain/endpoints/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go index 4cd330505..17984ee86 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -7,9 +7,11 @@ package callback import ( "net/http" "net/url" + "strings" "github.com/ory/fosite" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apiserver/pkg/audit" "go.pinniped.dev/internal/auditevent" "go.pinniped.dev/internal/federationdomain/downstreamsession" @@ -152,9 +154,43 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) if authcode(r) == "" { plog.Info("code param not found") - return "", nil, httperr.New(http.StatusBadRequest, - "code param not found: check URL in browser's address bar for error parameters from upstream identity provider") + return "", nil, httperr.New(http.StatusBadRequest, errorMsgForNoCodeParam(r)) } return encodedState, decodedState, nil } + +func errorMsgForNoCodeParam(r *http.Request) string { + msg := strings.Builder{} + + msg.WriteString("code param not found\n\n") + + errorParam, hasError := r.Form["error"] + errorDescParam, hasErrorDesc := r.Form["error_description"] + errorURIParam, hasErrorURI := r.Form["error_uri"] + + if hasError { + msg.WriteString("error from external identity provider: ") + msg.WriteString(errorParam[0]) + msg.WriteByte('\n') + } + if hasErrorDesc { + msg.WriteString("error_description from external identity provider: ") + msg.WriteString(errorDescParam[0]) + msg.WriteByte('\n') + } + if hasErrorURI { + msg.WriteString("error_uri from external identity provider: ") + msg.WriteString(errorURIParam[0]) + msg.WriteByte('\n') + } + if !hasError && !hasErrorDesc && !hasErrorURI { + msg.WriteString("Something went wrong with your authentication attempt at your external identity provider.\n") + } + + msg.WriteByte('\n') + msg.WriteString("Pinniped AuditID: ") + msg.WriteString(audit.GetAuditIDTruncated(r.Context())) + + return msg.String() +} diff --git a/internal/federationdomain/endpoints/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go index 6dd419553..c05770731 100644 --- a/internal/federationdomain/endpoints/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -29,6 +29,7 @@ import ( "go.pinniped.dev/internal/federationdomain/stateparam" "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" @@ -1180,36 +1181,75 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "error redirect from upstream IDP audit logs the error params from the OAuth2 spec", + name: "error redirect from upstream IDP audit logs all the error params from the OAuth2 spec", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, - path: newRequestPath().WithState(happyOIDCState).WithoutCode().String() + "&error=some_error&error_description=some_description&error_uri=some_uri", + path: newRequestPath().WithState(happyOIDCState).WithoutCode().String() + "&error=some%20error&error_description=some%20description&error_uri=some%20uri", csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, - wantBody: "Bad Request: code param not found: check URL in browser's address bar for error parameters from upstream identity provider\n", + wantBody: here.Doc(`Bad Request: code param not found + + error from external identity provider: some error + error_description from external identity provider: some description + error_uri from external identity provider: some uri + + Pinniped AuditID: fake-audit-id + `), wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ "state": "redacted", - "error": "some_error", - "error_description": "some_description", - "error_uri": "some_uri", + "error": "some error", + "error_description": "some description", + "error_uri": "some uri", }, }), } }, }, { - name: "code param was not included on request", + name: "error redirect from upstream IDP when only some of the error params from the OAuth2 spec are included on the URL", + idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyOIDCState).WithoutCode().String() + "&error=some%20error&error_description=some%20description", + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: here.Doc(`Bad Request: code param not found + + error from external identity provider: some error + error_description from external identity provider: some description + + Pinniped AuditID: fake-audit-id + `), + wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "state": "redacted", + "error": "some error", + "error_description": "some description", + }, + }), + } + }, + }, + { + name: "code param was not included on request and there is no error param", idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream().Build()), method: http.MethodGet, path: newRequestPath().WithState(happyOIDCState).WithoutCode().String(), csrfCookie: happyCSRFCookie, wantStatus: http.StatusBadRequest, wantContentType: htmlContentType, - wantBody: "Bad Request: code param not found: check URL in browser's address bar for error parameters from upstream identity provider\n", + wantBody: here.Doc(`Bad Request: code param not found + + Something went wrong with your authentication attempt at your external identity provider. + + Pinniped AuditID: fake-audit-id + `), wantAuditLogs: func(encodedStateParam stateparam.Encoded, sessionID string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ From 032160a85efd1e86d87ee86ccc986a49b316ec37 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 13:02:27 -0800 Subject: [PATCH 69/71] simplify single-node.yaml Co-authored-by: Joshua Casey --- hack/lib/kind-config/single-node.yaml | 90 ++++++++++++--------------- 1 file changed, 39 insertions(+), 51 deletions(-) diff --git a/hack/lib/kind-config/single-node.yaml b/hack/lib/kind-config/single-node.yaml index 71202f09c..56b05f666 100644 --- a/hack/lib/kind-config/single-node.yaml +++ b/hack/lib/kind-config/single-node.yaml @@ -19,58 +19,46 @@ nodes: containerPort: 31235 hostPort: 12346 listenAddress: 127.0.0.1 - - - #! Kind v0.12.0 ignores kubeadm.k8s.io/v1beta2 for Kube v1.23+ but uses it for older versions of Kube. - #! Previous versions of Kind would use kubeadm.k8s.io/v1beta2 for all versions of Kube including 1.23. - #! To try to maximize compatibility with various versions of Kind and Kube, define this - #! ClusterConfiguration twice and hope that Kind will use the one that it likes for the given version - #! of Kube, and ignore the one that it doesn't like. This seems to work, at least for Kind v0.12.0. - kubeadmConfigPatches: - - | - apiVersion: kubeadm.k8s.io/v1beta2 - kind: ClusterConfiguration - apiServer: - extraArgs: - #! To make sure the endpoints on our service are correct (this mostly matters for kubectl based - #! installs where kapp is not doing magic changes to the deployment and service selectors). - #! Setting this field to true makes it so that the API service will do the service cluster IP - #! to endpoint IP translations internally instead of relying on the network stack (i.e. kube-proxy). - #! The logic inside the API server is very straightforward - randomly pick an IP from the list - #! of available endpoints. This means that over time, all endpoints associated with the service - #! are exercised. For whatever reason, leaving this as false (i.e. use kube-proxy) appears to - #! hide some network misconfigurations when used internally by the API server aggregation layer. - enable-aggregator-routing: "true" - - | - apiVersion: kubeadm.k8s.io/v1beta3 - kind: ClusterConfiguration - apiServer: - extraArgs: - # See comment above. - enable-aggregator-routing: "true" #@ if data.values.enable_audit_logs: - - | - kind: ClusterConfiguration - apiServer: - #! enable auditing flags on the API server - extraArgs: - audit-log-path: /var/log/kubernetes/kube-apiserver-audit.log - audit-policy-file: /etc/kubernetes/policies/audit-policy.yaml - #! mount new files / directories on the control plane - extraVolumes: - - name: audit-policies - hostPath: /etc/kubernetes/policies - mountPath: /etc/kubernetes/policies - readOnly: true - pathType: "DirectoryOrCreate" - - name: "audit-logs" - hostPath: "/var/log/kubernetes" - mountPath: "/var/log/kubernetes" - readOnly: false - pathType: DirectoryOrCreate #! mount the local file on the control plane extraMounts: - - hostPath: /tmp/metadata-audit-policy.yaml - containerPath: /etc/kubernetes/policies/audit-policy.yaml - readOnly: true + - hostPath: /tmp/metadata-audit-policy.yaml + containerPath: /etc/kubernetes/policies/audit-policy.yaml + readOnly: true #@ end +#! Apply these patches to all nodes. +kubeadmConfigPatches: +- | + kind: ClusterConfiguration + apiServer: + extraArgs: + #! To make sure the endpoints on our service are correct (this mostly matters for kubectl based + #! installs where kapp is not doing magic changes to the deployment and service selectors). + #! Setting this field to true makes it so that the API service will do the service cluster IP + #! to endpoint IP translations internally instead of relying on the network stack (i.e. kube-proxy). + #! The logic inside the API server is very straightforward - randomly pick an IP from the list + #! of available endpoints. This means that over time, all endpoints associated with the service + #! are exercised. For whatever reason, leaving this as false (i.e. use kube-proxy) appears to + #! hide some network misconfigurations when used internally by the API server aggregation layer. + enable-aggregator-routing: "true" +#@ if data.values.enable_audit_logs: +- | + kind: ClusterConfiguration + apiServer: + #! enable auditing flags on the API server + extraArgs: + audit-log-path: /var/log/kubernetes/kube-apiserver-audit.log + audit-policy-file: /etc/kubernetes/policies/audit-policy.yaml + #! mount new files / directories on the control plane + extraVolumes: + - name: audit-policies + hostPath: /etc/kubernetes/policies + mountPath: /etc/kubernetes/policies + readOnly: true + pathType: "DirectoryOrCreate" + - name: "audit-logs" + hostPath: "/var/log/kubernetes" + mountPath: "/var/log/kubernetes" + readOnly: false + pathType: DirectoryOrCreate +#@ end From ae5aad178d2efb577f0816186dc508da915f1338 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Nov 2024 15:18:43 -0800 Subject: [PATCH 70/71] TokenCredentialRequest uses actual cert expiry time instead of estimate and also audit logs both the NotBefore and NotAfter of the issued cert. Implemented by changing the return type of the cert issuer helpers to make them also return the NotBefore and NotAfter values of the new cert, along with the key PEM and cert PEM. --- internal/cert/pem.go | 13 ++++ internal/certauthority/certauthority.go | 34 +++++---- internal/certauthority/certauthority_test.go | 60 ++++++++------- .../dynamiccertauthority.go | 9 ++- .../dynamiccertauthority_test.go | 20 ++--- internal/clientcertissuer/issuer.go | 13 ++-- internal/clientcertissuer/issuer_test.go | 22 +++--- internal/concierge/apiserver/apiserver.go | 2 - .../impersonator/impersonator_test.go | 10 +-- .../apicerts/certs_observer_test.go | 12 +-- .../webhookcachefiller_test.go | 4 +- .../github_upstream_watcher_test.go | 6 +- internal/dynamiccert/provider_test.go | 16 ++-- internal/mocks/mockissuer/mockissuer.go | 10 +-- internal/registry/credentialrequest/rest.go | 22 +++--- .../registry/credentialrequest/rest_test.go | 74 +++++++++++-------- site/content/docs/reference/audit-logging.md | 5 +- test/integration/audit_test.go | 12 +-- .../concierge_impersonation_proxy_test.go | 14 ++-- 19 files changed, 199 insertions(+), 159 deletions(-) create mode 100644 internal/cert/pem.go diff --git a/internal/cert/pem.go b/internal/cert/pem.go new file mode 100644 index 000000000..1a6b4d458 --- /dev/null +++ b/internal/cert/pem.go @@ -0,0 +1,13 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cert + +import "time" + +type PEM struct { + CertPEM []byte + KeyPEM []byte + NotBefore time.Time + NotAfter time.Time +} diff --git a/internal/certauthority/certauthority.go b/internal/certauthority/certauthority.go index ab9e086d2..76ef5d97f 100644 --- a/internal/certauthority/certauthority.go +++ b/internal/certauthority/certauthority.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package certauthority implements a simple x509 certificate authority suitable for use in an aggregated API service. @@ -20,6 +20,7 @@ import ( "net" "time" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/constable" ) @@ -38,7 +39,7 @@ type env struct { // clock tells the current time (usually time.Now(), but broken out here for tests). clock func() time.Time - // parse function to parse an ASN.1 byte slice into an x509 struct (normally x509.ParseCertificate) + // parse function to parse an ASN.1 byte slice into a x509 struct (normally x509.ParseCertificate) parseCert func([]byte) (*x509.Certificate, error) } @@ -180,19 +181,19 @@ func (c *CA) IssueServerCert(dnsNames []string, ips []net.IP, ttl time.Duration) } // IssueClientCertPEM is similar to IssueClientCert, but returns the new cert as a pair of PEM-formatted byte slices -// for the certificate and private key. -func (c *CA) IssueClientCertPEM(username string, groups []string, ttl time.Duration) ([]byte, []byte, error) { +// for the certificate and private key, along with the notBefore and notAfter values. +func (c *CA) IssueClientCertPEM(username string, groups []string, ttl time.Duration) (*cert.PEM, error) { return toPEM(c.IssueClientCert(username, groups, ttl)) } // IssueServerCertPEM is similar to IssueServerCert, but returns the new cert as a pair of PEM-formatted byte slices -// for the certificate and private key. -func (c *CA) IssueServerCertPEM(dnsNames []string, ips []net.IP, ttl time.Duration) ([]byte, []byte, error) { +// for the certificate and private key, along with the notBefore and notAfter values. +func (c *CA) IssueServerCertPEM(dnsNames []string, ips []net.IP, ttl time.Duration) (*cert.PEM, error) { return toPEM(c.IssueServerCert(dnsNames, ips, ttl)) } func (c *CA) issueCert(extKeyUsage x509.ExtKeyUsage, subject pkix.Name, dnsNames []string, ips []net.IP, ttl time.Duration) (*tls.Certificate, error) { - // Choose a random 128 bit serial number. + // Choose a random 128-bit serial number. serialNumber, err := randomSerial(c.env.serialRNG) if err != nil { return nil, fmt.Errorf("could not generate serial number for certificate: %w", err) @@ -209,7 +210,7 @@ func (c *CA) issueCert(extKeyUsage x509.ExtKeyUsage, subject pkix.Name, dnsNames notBefore := now.Add(-certBackdate) notAfter := now.Add(ttl) - // Parse the DER encoded certificate to get an x509.Certificate. + // Parse the DER encoded certificate to get a x509.Certificate. caCert, err := x509.ParseCertificate(c.caCertBytes) if err != nil { return nil, fmt.Errorf("could not parse CA certificate: %w", err) @@ -246,18 +247,23 @@ func (c *CA) issueCert(extKeyUsage x509.ExtKeyUsage, subject pkix.Name, dnsNames }, nil } -func toPEM(cert *tls.Certificate, err error) ([]byte, []byte, error) { +func toPEM(certificate *tls.Certificate, err error) (*cert.PEM, error) { // If the wrapped IssueServerCert() returned an error, pass it back. if err != nil { - return nil, nil, err + return nil, err } - certPEM, keyPEM, err := ToPEM(cert) + certPEM, keyPEM, err := ToPEM(certificate) if err != nil { - return nil, nil, err + return nil, err } - return certPEM, keyPEM, nil + return &cert.PEM{ + CertPEM: certPEM, + KeyPEM: keyPEM, + NotBefore: certificate.Leaf.NotBefore, + NotAfter: certificate.Leaf.NotAfter, + }, nil } // ToPEM encodes a tls.Certificate into a private key PEM and a cert chain PEM. @@ -279,7 +285,7 @@ func ToPEM(cert *tls.Certificate) ([]byte, []byte, error) { return certPEM, keyPEM, nil } -// randomSerial generates a random 128 bit serial number. +// randomSerial generates a random 128-bit serial number. func randomSerial(rng io.Reader) (*big.Int, error) { return rand.Int(rng, new(big.Int).Lsh(big.NewInt(1), 128)) } diff --git a/internal/certauthority/certauthority_test.go b/internal/certauthority/certauthority_test.go index 0128b1ad0..b59eeb0f5 100644 --- a/internal/certauthority/certauthority_test.go +++ b/internal/certauthority/certauthority_test.go @@ -228,7 +228,7 @@ func (e *errSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, er func TestIssue(t *testing.T) { const numRandBytes = 64 * 2 // each call to issue a cert will consume 64 bytes from the reader - now := time.Date(2020, 7, 10, 12, 41, 12, 1234, time.UTC) + now := time.Date(2020, 7, 10, 12, 41, 12, 0, time.UTC) realCA, err := Load(testCert, testKey) require.NoError(t, err) @@ -323,6 +323,8 @@ func TestIssue(t *testing.T) { } else { require.NoError(t, err) require.NotNil(t, got) + require.Equal(t, now.Add(-5*time.Minute), got.Leaf.NotBefore) // always back-dated + require.Equal(t, now.Add(10*time.Minute), got.Leaf.NotAfter) } got, err = tt.ca.IssueClientCert("test-user", []string{"group1", "group2"}, 10*time.Minute) if tt.wantErr != "" { @@ -331,6 +333,8 @@ func TestIssue(t *testing.T) { } else { require.NoError(t, err) require.NotNil(t, got) + require.Equal(t, now.Add(-5*time.Minute), got.Leaf.NotBefore) // always back-dated + require.Equal(t, now.Add(10*time.Minute), got.Leaf.NotAfter) } }) } @@ -341,26 +345,26 @@ func TestToPEM(t *testing.T) { require.NoError(t, err) t.Run("error from input", func(t *testing.T) { - certPEM, keyPEM, err := toPEM(nil, fmt.Errorf("some error")) + pem, err := toPEM(nil, fmt.Errorf("some error")) require.EqualError(t, err, "some error") - require.Nil(t, certPEM) - require.Nil(t, keyPEM) + require.Nil(t, pem) }) t.Run("invalid private key", func(t *testing.T) { cert := realCert cert.PrivateKey = nil - certPEM, keyPEM, err := toPEM(&cert, nil) + pem, err := toPEM(&cert, nil) require.EqualError(t, err, "failed to marshal private key into PKCS8: x509: unknown key type while marshaling PKCS#8: ") - require.Nil(t, certPEM) - require.Nil(t, keyPEM) + require.Nil(t, pem) }) t.Run("success", func(t *testing.T) { - certPEM, keyPEM, err := toPEM(&realCert, nil) + pem, err := toPEM(&realCert, nil) require.NoError(t, err) - require.NotEmpty(t, certPEM) - require.NotEmpty(t, keyPEM) + require.NotEmpty(t, pem.CertPEM) + require.NotEmpty(t, pem.KeyPEM) + require.Equal(t, time.Date(2020, time.July, 25, 21, 4, 18, 0, time.UTC), pem.NotBefore) + require.Equal(t, time.Date(2030, time.July, 23, 21, 4, 18, 0, time.UTC), pem.NotAfter) }) } @@ -381,21 +385,21 @@ func TestIssueMethods(t *testing.T) { require.NoError(t, err) validateClientCert(t, ca.Bundle(), certPEM, keyPEM, user, groups, ttl) - certPEM, keyPEM, err = ca.IssueClientCertPEM(user, groups, ttl) + pem, err := ca.IssueClientCertPEM(user, groups, ttl) require.NoError(t, err) - validateClientCert(t, ca.Bundle(), certPEM, keyPEM, user, groups, ttl) + validateClientCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, user, groups, ttl) - certPEM, keyPEM, err = ca.IssueClientCertPEM(user, nil, ttl) + pem, err = ca.IssueClientCertPEM(user, nil, ttl) require.NoError(t, err) - validateClientCert(t, ca.Bundle(), certPEM, keyPEM, user, nil, ttl) + validateClientCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, user, nil, ttl) - certPEM, keyPEM, err = ca.IssueClientCertPEM(user, []string{}, ttl) + pem, err = ca.IssueClientCertPEM(user, []string{}, ttl) require.NoError(t, err) - validateClientCert(t, ca.Bundle(), certPEM, keyPEM, user, nil, ttl) + validateClientCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, user, nil, ttl) - certPEM, keyPEM, err = ca.IssueClientCertPEM("", []string{}, ttl) + pem, err = ca.IssueClientCertPEM("", []string{}, ttl) require.NoError(t, err) - validateClientCert(t, ca.Bundle(), certPEM, keyPEM, "", nil, ttl) + validateClientCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, "", nil, ttl) }) t.Run("server certs", func(t *testing.T) { @@ -408,25 +412,25 @@ func TestIssueMethods(t *testing.T) { require.NoError(t, err) validateServerCert(t, ca.Bundle(), certPEM, keyPEM, dnsNames, ips, ttl) - certPEM, keyPEM, err = ca.IssueServerCertPEM(dnsNames, ips, ttl) + pem, err := ca.IssueServerCertPEM(dnsNames, ips, ttl) require.NoError(t, err) - validateServerCert(t, ca.Bundle(), certPEM, keyPEM, dnsNames, ips, ttl) + validateServerCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, dnsNames, ips, ttl) - certPEM, keyPEM, err = ca.IssueServerCertPEM(nil, ips, ttl) + pem, err = ca.IssueServerCertPEM(nil, ips, ttl) require.NoError(t, err) - validateServerCert(t, ca.Bundle(), certPEM, keyPEM, nil, ips, ttl) + validateServerCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, nil, ips, ttl) - certPEM, keyPEM, err = ca.IssueServerCertPEM(dnsNames, nil, ttl) + pem, err = ca.IssueServerCertPEM(dnsNames, nil, ttl) require.NoError(t, err) - validateServerCert(t, ca.Bundle(), certPEM, keyPEM, dnsNames, nil, ttl) + validateServerCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, dnsNames, nil, ttl) - certPEM, keyPEM, err = ca.IssueServerCertPEM([]string{}, ips, ttl) + pem, err = ca.IssueServerCertPEM([]string{}, ips, ttl) require.NoError(t, err) - validateServerCert(t, ca.Bundle(), certPEM, keyPEM, nil, ips, ttl) + validateServerCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, nil, ips, ttl) - certPEM, keyPEM, err = ca.IssueServerCertPEM(dnsNames, []net.IP{}, ttl) + pem, err = ca.IssueServerCertPEM(dnsNames, []net.IP{}, ttl) require.NoError(t, err) - validateServerCert(t, ca.Bundle(), certPEM, keyPEM, dnsNames, nil, ttl) + validateServerCert(t, ca.Bundle(), pem.CertPEM, pem.KeyPEM, dnsNames, nil, ttl) }) } diff --git a/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go b/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go index e75c7a257..197112797 100644 --- a/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go +++ b/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go @@ -1,4 +1,4 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package dynamiccertauthority implements a x509 certificate authority capable of issuing @@ -10,6 +10,7 @@ import ( "k8s.io/apiserver/pkg/server/dynamiccertificates" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/clientcertissuer" ) @@ -32,15 +33,15 @@ func (c *ca) Name() string { } // IssueClientCertPEM issues a new client certificate for the given identity and duration, returning it as a -// pair of PEM-formatted byte slices for the certificate and private key. -func (c *ca) IssueClientCertPEM(username string, groups []string, ttl time.Duration) ([]byte, []byte, error) { +// pair of PEM-formatted byte slices for the certificate and private key, along with the notBefore and notAfter values. +func (c *ca) IssueClientCertPEM(username string, groups []string, ttl time.Duration) (*cert.PEM, error) { caCrtPEM, caKeyPEM := c.provider.CurrentCertKeyContent() // in the future we could split dynamiccert.Private into two interfaces (Private and PrivateRead) // and have this code take PrivateRead as input. We would then add ourselves as a listener to // the PrivateRead. This would allow us to only reload the CA contents when they actually change. ca, err := certauthority.Load(string(caCrtPEM), string(caKeyPEM)) if err != nil { - return nil, nil, err + return nil, err } return ca.IssueClientCertPEM(username, groups, ttl) diff --git a/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go b/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go index 665f6d191..34935e845 100644 --- a/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go +++ b/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/testutil" @@ -93,36 +94,35 @@ func TestCAIssuePEM(t *testing.T) { // Can't run these steps in parallel, because each one depends on the previous steps being // run. - crtPEM, keyPEM, err := issuePEM(provider, ca, step.caCrtPEM, step.caKeyPEM) + pem, err := issuePEM(provider, ca, step.caCrtPEM, step.caKeyPEM) if step.wantError != "" { require.EqualError(t, err, step.wantError) - require.Empty(t, crtPEM) - require.Empty(t, keyPEM) + require.Nil(t, pem) } else { require.NoError(t, err) - require.NotEmpty(t, crtPEM) - require.NotEmpty(t, keyPEM) + require.NotEmpty(t, pem.CertPEM) + require.NotEmpty(t, pem.KeyPEM) caCrtPEM, _ := provider.CurrentCertKeyContent() - crtAssertions := testutil.ValidateClientCertificate(t, string(caCrtPEM), string(crtPEM)) + crtAssertions := testutil.ValidateClientCertificate(t, string(caCrtPEM), string(pem.CertPEM)) crtAssertions.RequireCommonName("some-username") crtAssertions.RequireOrganizations([]string{"some-group1", "some-group2"}) crtAssertions.RequireLifetime(time.Now(), time.Now().Add(time.Hour*24), time.Minute*10) - crtAssertions.RequireMatchesPrivateKey(string(keyPEM)) + crtAssertions.RequireMatchesPrivateKey(string(pem.KeyPEM)) } }) } } -func issuePEM(provider dynamiccert.Provider, ca clientcertissuer.ClientCertIssuer, caCrt, caKey []byte) ([]byte, []byte, error) { +func issuePEM(provider dynamiccert.Provider, ca clientcertissuer.ClientCertIssuer, caCrt, caKey []byte) (*cert.PEM, error) { // if setting fails, look at that error if caCrt != nil || caKey != nil { if err := provider.SetCertKeyContent(caCrt, caKey); err != nil { - return nil, nil, err + return nil, err } } - // otherwise check to see if their is an issuing error + // otherwise check to see if there is an issuing error return ca.IssueClientCertPEM("some-username", []string{"some-group1", "some-group2"}, time.Hour*24) } diff --git a/internal/clientcertissuer/issuer.go b/internal/clientcertissuer/issuer.go index f84f7beda..3d7901051 100644 --- a/internal/clientcertissuer/issuer.go +++ b/internal/clientcertissuer/issuer.go @@ -10,6 +10,7 @@ import ( utilerrors "k8s.io/apimachinery/pkg/util/errors" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/constable" ) @@ -17,7 +18,7 @@ const defaultCertIssuerErr = constable.Error("failed to issue cert") type ClientCertIssuer interface { Name() string - IssueClientCertPEM(username string, groups []string, ttl time.Duration) (certPEM, keyPEM []byte, err error) + IssueClientCertPEM(username string, groups []string, ttl time.Duration) (pem *cert.PEM, err error) } var _ ClientCertIssuer = ClientCertIssuers{} @@ -37,20 +38,20 @@ func (c ClientCertIssuers) Name() string { return strings.Join(names, ",") } -func (c ClientCertIssuers) IssueClientCertPEM(username string, groups []string, ttl time.Duration) ([]byte, []byte, error) { +func (c ClientCertIssuers) IssueClientCertPEM(username string, groups []string, ttl time.Duration) (*cert.PEM, error) { errs := make([]error, 0, len(c)) for _, issuer := range c { - certPEM, keyPEM, err := issuer.IssueClientCertPEM(username, groups, ttl) + pem, err := issuer.IssueClientCertPEM(username, groups, ttl) if err == nil { - return certPEM, keyPEM, nil + return pem, nil } errs = append(errs, fmt.Errorf("%s failed to issue client cert: %w", issuer.Name(), err)) } if err := utilerrors.NewAggregate(errs); err != nil { - return nil, nil, err + return nil, err } - return nil, nil, defaultCertIssuerErr + return nil, defaultCertIssuerErr } diff --git a/internal/clientcertissuer/issuer_test.go b/internal/clientcertissuer/issuer_test.go index 81a615971..a14e08607 100644 --- a/internal/clientcertissuer/issuer_test.go +++ b/internal/clientcertissuer/issuer_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/mocks/mockissuer" ) @@ -85,7 +86,7 @@ func TestIssueClientCertPEM(t *testing.T) { errClientCertIssuer.EXPECT().Name().Return("error cert issuer") errClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return(nil, nil, errors.New("error from wrapped cert issuer")) + Return(nil, errors.New("error from wrapped cert issuer")) return ClientCertIssuers{errClientCertIssuer} }, wantErrorMessage: "error cert issuer failed to issue client cert: error from wrapped cert issuer", @@ -96,7 +97,7 @@ func TestIssueClientCertPEM(t *testing.T) { validClientCertIssuer := mockissuer.NewMockClientCertIssuer(ctrl) validClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return([]byte("cert"), []byte("key"), nil) + Return(&cert.PEM{CertPEM: []byte("cert"), KeyPEM: []byte("key")}, nil) return ClientCertIssuers{validClientCertIssuer} }, wantCert: []byte("cert"), @@ -109,12 +110,12 @@ func TestIssueClientCertPEM(t *testing.T) { errClientCertIssuer.EXPECT().Name().Return("error cert issuer") errClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return(nil, nil, errors.New("error from wrapped cert issuer")) + Return(nil, errors.New("error from wrapped cert issuer")) validClientCertIssuer := mockissuer.NewMockClientCertIssuer(ctrl) validClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return([]byte("cert"), []byte("key"), nil) + Return(&cert.PEM{CertPEM: []byte("cert"), KeyPEM: []byte("key")}, nil) return ClientCertIssuers{ errClientCertIssuer, validClientCertIssuer, @@ -130,13 +131,13 @@ func TestIssueClientCertPEM(t *testing.T) { err1ClientCertIssuer.EXPECT().Name().Return("error1 cert issuer") err1ClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return(nil, nil, errors.New("error1 from wrapped cert issuer")) + Return(nil, errors.New("error1 from wrapped cert issuer")) err2ClientCertIssuer := mockissuer.NewMockClientCertIssuer(ctrl) err2ClientCertIssuer.EXPECT().Name().Return("error2 cert issuer") err2ClientCertIssuer.EXPECT(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second). - Return(nil, nil, errors.New("error2 from wrapped cert issuer")) + Return(nil, errors.New("error2 from wrapped cert issuer")) return ClientCertIssuers{ err1ClientCertIssuer, @@ -152,17 +153,16 @@ func TestIssueClientCertPEM(t *testing.T) { t.Run(testcase.name, func(t *testing.T) { t.Parallel() - certPEM, keyPEM, err := testcase.buildIssuerMocks(). + pem, err := testcase.buildIssuerMocks(). IssueClientCertPEM("username", []string{"group1", "group2"}, 32*time.Second) if testcase.wantErrorMessage != "" { require.ErrorContains(t, err, testcase.wantErrorMessage) - require.Empty(t, certPEM) - require.Empty(t, keyPEM) + require.Nil(t, pem) } else { require.NoError(t, err) - require.Equal(t, testcase.wantCert, certPEM) - require.Equal(t, testcase.wantKey, keyPEM) + require.Equal(t, testcase.wantCert, pem.CertPEM) + require.Equal(t, testcase.wantKey, pem.KeyPEM) } }) } diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index b58a4b2c2..88d38f922 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -15,7 +15,6 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" utilversion "k8s.io/apiserver/pkg/util/version" - "k8s.io/utils/clock" "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/controllerinit" @@ -89,7 +88,6 @@ func (c completedConfig) New() (*PinnipedServer, error) { c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource(), c.ExtraConfig.AuditLogger, - clock.RealClock{}, ) return tokenCredReqGVR, tokenCredStorage }, diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go index ede5ba385..aa49eaed2 100644 --- a/internal/concierge/impersonator/impersonator_test.go +++ b/internal/concierge/impersonator/impersonator_test.go @@ -70,10 +70,10 @@ func TestImpersonator(t *testing.T) { err = caContent.SetCertKeyContent(ca.Bundle(), caKey) require.NoError(t, err) - cert, key, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour) + pem, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour) require.NoError(t, err) certKeyContent := dynamiccert.NewServingCert("cert-key") - err = certKeyContent.SetCertKeyContent(cert, key) + err = certKeyContent.SetCertKeyContent(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) unrelatedCA, err := certauthority.New("ca", time.Hour) @@ -1997,11 +1997,11 @@ type clientCert struct { func newClientCert(t *testing.T, ca *certauthority.CA, username string, groups []string) *clientCert { t.Helper() - certPEM, keyPEM, err := ca.IssueClientCertPEM(username, groups, time.Hour) + pem, err := ca.IssueClientCertPEM(username, groups, time.Hour) require.NoError(t, err) return &clientCert{ - certPEM: certPEM, - keyPEM: keyPEM, + certPEM: pem.CertPEM, + keyPEM: pem.KeyPEM, } } diff --git a/internal/controller/apicerts/certs_observer_test.go b/internal/controller/apicerts/certs_observer_test.go index 245fba1f0..ccb11929e 100644 --- a/internal/controller/apicerts/certs_observer_test.go +++ b/internal/controller/apicerts/certs_observer_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package apicerts @@ -173,10 +173,10 @@ func TestObserverControllerSync(t *testing.T) { ca, err := certauthority.Load(string(caCrt), string(caKey)) require.NoError(t, err) - crt, key, err := ca.IssueServerCertPEM(nil, nil, time.Hour) + pem, err := ca.IssueServerCertPEM(nil, nil, time.Hour) require.NoError(t, err) - err = dynamicCertProvider.SetCertKeyContent(crt, key) + err = dynamicCertProvider.SetCertKeyContent(pem.CertPEM, pem.KeyPEM) r.NoError(err) }) @@ -202,7 +202,7 @@ func TestObserverControllerSync(t *testing.T) { ca, err := certauthority.Load(string(caCrt), string(caKey)) require.NoError(t, err) - crt, key, err := ca.IssueServerCertPEM(nil, nil, time.Hour) + pem, err := ca.IssueServerCertPEM(nil, nil, time.Hour) require.NoError(t, err) apiServingCertSecret := &corev1.Secret{ @@ -212,8 +212,8 @@ func TestObserverControllerSync(t *testing.T) { }, Data: map[string][]byte{ "caCertificate": []byte("fake cert"), - "tlsPrivateKey": key, - "tlsCertificateChain": crt, + "tlsPrivateKey": pem.KeyPEM, + "tlsCertificateChain": pem.CertPEM, }, } err = kubeInformerClient.Tracker().Add(apiServingCertSecret) diff --git a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go index b1654be82..89ce4cdaa 100644 --- a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go +++ b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go @@ -79,7 +79,7 @@ func TestController(t *testing.T) { require.NoError(t, err) someUnknownHostNames := []string{"some-dns-name", "some-other-dns-name"} someLocalIPAddress := []net.IP{net.ParseIP("10.2.3.4")} - pemServerCertForUnknownServer, _, err := caForUnknownServer.IssueServerCertPEM( + pemServerCertForUnknownServer, err := caForUnknownServer.IssueServerCertPEM( someUnknownHostNames, someLocalIPAddress, time.Hour, @@ -216,7 +216,7 @@ func TestController(t *testing.T) { badWebhookAuthenticatorSpecGoodEndpointButUnknownCA := authenticationv1alpha1.WebhookAuthenticatorSpec{ Endpoint: goodWebhookDefaultServingCertEndpoint, TLS: &authenticationv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemServerCertForUnknownServer), + CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemServerCertForUnknownServer.CertPEM), }, } diff --git a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go index eb4325c9c..c65b300fb 100644 --- a/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/githubupstreamwatcher/github_upstream_watcher_test.go @@ -127,7 +127,7 @@ func TestController(t *testing.T) { caForUnknownServer, err := certauthority.New("Some Unknown CA", time.Hour) require.NoError(t, err) - unknownServerCABytes, _, err := caForUnknownServer.IssueServerCertPEM( + unknownServerPEM, err := caForUnknownServer.IssueServerCertPEM( []string{"some-dns-name", "some-other-dns-name"}, []net.IP{net.ParseIP("10.2.3.4")}, time.Hour, @@ -1849,7 +1849,7 @@ func TestController(t *testing.T) { func() runtime.Object { badIDP := validFilledOutIDP.DeepCopy() badIDP.Spec.GitHubAPI.TLS = &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(unknownServerCABytes), + CertificateAuthorityData: base64.StdEncoding.EncodeToString(unknownServerPEM.CertPEM), } return badIDP }(), @@ -1861,7 +1861,7 @@ func TestController(t *testing.T) { Spec: func() idpv1alpha1.GitHubIdentityProviderSpec { badSpec := validFilledOutIDP.Spec.DeepCopy() badSpec.GitHubAPI.TLS = &idpv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(unknownServerCABytes), + CertificateAuthorityData: base64.StdEncoding.EncodeToString(unknownServerPEM.CertPEM), } return *badSpec }(), diff --git a/internal/dynamiccert/provider_test.go b/internal/dynamiccert/provider_test.go index 385adc364..ca55f5d8e 100644 --- a/internal/dynamiccert/provider_test.go +++ b/internal/dynamiccert/provider_test.go @@ -114,13 +114,13 @@ func TestProviderWithDynamicServingCertificateController(t *testing.T) { newCA, err := certauthority.New(names.SimpleNameGenerator.GenerateName("new-ca"), time.Hour) require.NoError(t, err) - certPEM, keyPEM, err := newCA.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.2")}, time.Hour) + pem, err := newCA.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.2")}, time.Hour) require.NoError(t, err) - err = certKey.SetCertKeyContent(certPEM, keyPEM) + err = certKey.SetCertKeyContent(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) - cert, err := tls.X509KeyPair(certPEM, keyPEM) + cert, err := tls.X509KeyPair(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) return []tls.Certificate{cert} @@ -144,13 +144,13 @@ func TestProviderWithDynamicServingCertificateController(t *testing.T) { newCA, err := certauthority.New(names.SimpleNameGenerator.GenerateName("new-ca"), time.Hour) require.NoError(t, err) - certPEM, keyPEM, err := newCA.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.3")}, time.Hour) + pem, err := newCA.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.3")}, time.Hour) require.NoError(t, err) - err = certKey.SetCertKeyContent(certPEM, keyPEM) + err = certKey.SetCertKeyContent(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) - cert, err := tls.X509KeyPair(certPEM, keyPEM) + cert, err := tls.X509KeyPair(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) return []tls.Certificate{cert} @@ -170,10 +170,10 @@ func TestProviderWithDynamicServingCertificateController(t *testing.T) { err = caContent.SetCertKeyContent(ca.Bundle(), caKey) require.NoError(t, err) - cert, key, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour) + pem, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour) require.NoError(t, err) certKeyContent := NewServingCert("cert-key") - err = certKeyContent.SetCertKeyContent(cert, key) + err = certKeyContent.SetCertKeyContent(pem.CertPEM, pem.KeyPEM) require.NoError(t, err) tlsConfig := ptls.Default(nil) diff --git a/internal/mocks/mockissuer/mockissuer.go b/internal/mocks/mockissuer/mockissuer.go index 1950e3dd8..4a22d01de 100644 --- a/internal/mocks/mockissuer/mockissuer.go +++ b/internal/mocks/mockissuer/mockissuer.go @@ -17,6 +17,7 @@ import ( reflect "reflect" time "time" + cert "go.pinniped.dev/internal/cert" gomock "go.uber.org/mock/gomock" ) @@ -45,13 +46,12 @@ func (m *MockClientCertIssuer) EXPECT() *MockClientCertIssuerMockRecorder { } // IssueClientCertPEM mocks base method. -func (m *MockClientCertIssuer) IssueClientCertPEM(username string, groups []string, ttl time.Duration) ([]byte, []byte, error) { +func (m *MockClientCertIssuer) IssueClientCertPEM(username string, groups []string, ttl time.Duration) (*cert.PEM, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IssueClientCertPEM", username, groups, ttl) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].([]byte) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret0, _ := ret[0].(*cert.PEM) + ret1, _ := ret[1].(error) + return ret0, ret1 } // IssueClientCertPEM indicates an expected call of IssueClientCertPEM. diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index a8ef1c318..6edd83340 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -20,7 +20,6 @@ import ( "k8s.io/apiserver/pkg/authentication/user" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" - "k8s.io/utils/clock" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/auditevent" @@ -40,14 +39,12 @@ func NewREST( issuer clientcertissuer.ClientCertIssuer, resource schema.GroupResource, auditLogger plog.AuditLogger, - clock clock.Clock, ) *REST { return &REST{ authenticator: authenticator, issuer: issuer, tableConvertor: rest.NewDefaultTableConvertor(resource), auditLogger: auditLogger, - clock: clock, } } @@ -56,7 +53,6 @@ type REST struct { issuer clientcertissuer.ClientCertIssuer tableConvertor rest.TableConvertor auditLogger plog.AuditLogger - clock clock.Clock } // Assert that our *REST implements all the optional interfaces that we expect it to implement. @@ -162,9 +158,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return authenticationFailedResponse(), nil } - // this timestamp should be returned from IssueClientCertPEM but this is a safe approximation - expires := metav1.NewTime(r.clock.Now().UTC().Add(clientCertificateTTL)) - certPEM, keyPEM, err := r.issuer.IssueClientCertPEM(userInfo.GetName(), userInfo.GetGroups(), clientCertificateTTL) + pem, err := r.issuer.IssueClientCertPEM(userInfo.GetName(), userInfo.GetGroups(), clientCertificateTTL) if err != nil { r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{ ReqCtx: ctx, @@ -177,6 +171,9 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return authenticationFailedResponse(), nil } + notBefore := metav1.NewTime(pem.NotBefore) + notAfter := metav1.NewTime(pem.NotAfter) + r.auditLogger.Audit(auditevent.TokenCredentialRequestAuthenticatedUser, &plog.AuditParams{ ReqCtx: ctx, PIIKeysAndValues: []any{ @@ -184,7 +181,10 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation "groups", userInfo.GetGroups(), }, KeysAndValues: []any{ - "issuedClientCertExpires", expires.Format(time.RFC3339), + "issuedClientCert", map[string]string{ + "notBefore": notBefore.Format(time.RFC3339), + "notAfter": notAfter.Format(time.RFC3339), + }, "authenticator", credentialRequest.Spec.Authenticator, }, }) @@ -192,9 +192,9 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ Credential: &loginapi.ClusterCredential{ - ExpirationTimestamp: expires, - ClientCertificateData: string(certPEM), - ClientKeyData: string(keyPEM), + ExpirationTimestamp: notAfter, + ClientCertificateData: string(pem.CertPEM), + ClientKeyData: string(pem.KeyPEM), }, }, }, nil diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 854746738..2bbf38a9b 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -24,11 +24,10 @@ import ( "k8s.io/apiserver/pkg/authentication/user" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" - "k8s.io/utils/clock" - clocktesting "k8s.io/utils/clock/testing" "k8s.io/utils/ptr" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" + "go.pinniped.dev/internal/cert" "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/mocks/mockcredentialrequest" "go.pinniped.dev/internal/mocks/mockissuer" @@ -37,7 +36,7 @@ import ( ) func TestNew(t *testing.T) { - r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, nil, clock.RealClock{}) + r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"}, nil) require.NotNil(t, r) require.False(t, r.NamespaceScoped()) require.Equal(t, []string{"pinniped"}, r.Categories()) @@ -78,16 +77,14 @@ func TestCreate(t *testing.T) { var ctrl *gomock.Controller var auditLogger plog.AuditLogger var actualAuditLog *bytes.Buffer - var frozenNow time.Time - var frozenClock *clocktesting.FakeClock + var fakeNow time.Time var wantAuditLog []testutil.WantedAuditLog it.Before(func() { r = require.New(t) ctrl = gomock.NewController(t) auditLogger, actualAuditLog = plog.TestAuditLogger(t) - frozenNow = time.Date(2024, time.September, 12, 4, 25, 56, 778899, time.UTC) - frozenClock = clocktesting.NewFakeClock(frozenNow) + fakeNow = time.Date(2024, time.September, 12, 4, 25, 56, 778899, time.UTC) }) it.After(func() { @@ -110,9 +107,14 @@ func TestCreate(t *testing.T) { "test-user", []string{"test-group-1", "test-group-2"}, 5*time.Minute, - ).Return([]byte("test-cert"), []byte("test-key"), nil) + ).Return(&cert.PEM{ + CertPEM: []byte("test-cert"), + KeyPEM: []byte("test-key"), + NotBefore: fakeNow.Add(-5 * time.Minute), + NotAfter: fakeNow.Add(5 * time.Minute), + }, nil) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -122,7 +124,7 @@ func TestCreate(t *testing.T) { r.Equal(response, &loginapi.TokenCredentialRequest{ Status: loginapi.TokenCredentialRequestStatus{ Credential: &loginapi.ClusterCredential{ - ExpirationTimestamp: metav1.NewTime(frozenNow.Add(5 * time.Minute).UTC()), + ExpirationTimestamp: metav1.NewTime(fakeNow.Add(5 * time.Minute).UTC()), ClientCertificateData: "test-cert", ClientKeyData: "test-key", }, @@ -141,7 +143,10 @@ func TestCreate(t *testing.T) { "kind": "FakeAuthenticatorKind", "name": "fake-authenticator-name", }, - "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "issuedClientCert": map[string]any{ + "notBefore": "2024-09-12T04:20:56Z", // this is fakeNow - 5 minutes in UTC + "notAfter": "2024-09-12T04:30:56Z", // this is fakeNow + 5 minutes in UTC + }, "personalInfo": map[string]any{ "username": "test-user", "groups": []any{"test-group-1", "test-group-2"}, @@ -163,9 +168,9 @@ func TestCreate(t *testing.T) { clientCertIssuer := mockissuer.NewMockClientCertIssuer(ctrl) clientCertIssuer.EXPECT(). IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, nil, fmt.Errorf("some certificate authority error")) + Return(nil, fmt.Errorf("some certificate authority error")) - storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) @@ -194,7 +199,7 @@ func TestCreate(t *testing.T) { requestAuthenticator := mockcredentialrequest.NewMockTokenCredentialRequestAuthenticator(ctrl) requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -224,7 +229,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(nil, errors.New("some webhook error")) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -255,7 +260,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req). Return(&user.DefaultInfo{Name: "", UID: "test-uid"}, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -295,7 +300,7 @@ func TestCreate(t *testing.T) { Groups: []string{"test-group-1", "test-group-2"}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -335,7 +340,7 @@ func TestCreate(t *testing.T) { Extra: map[string][]string{"test-key": {"test-val-1", "test-val-2"}}, }, nil) - storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, req) @@ -366,7 +371,7 @@ func TestCreate(t *testing.T) { it("CreateFailsWhenGivenTheWrongInputType", func() { notACredentialRequest := runtime.Unknown{} - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.NewContext(), ¬ACredentialRequest, rest.ValidateAllObjectFunc, @@ -376,7 +381,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenTokenValueIsEmptyInRequest", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) response, err := callCreate(storage, credentialRequest(loginapi.TokenCredentialRequestSpec{ Token: "", })) @@ -386,7 +391,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenValidationFails", func() { - storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(nil, nil, schema.GroupResource{}, auditLogger) response, err := storage.Create( context.Background(), validCredentialRequest(), @@ -408,7 +413,7 @@ func TestCreate(t *testing.T) { fakeReqContext := audit.WithAuditContext(context.Background()) audit.WithAuditID(fakeReqContext, "fake-audit-id") - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl, fakeNow), schema.GroupResource{}, auditLogger) response, err := storage.Create( fakeReqContext, req, @@ -433,7 +438,10 @@ func TestCreate(t *testing.T) { "kind": "FakeAuthenticatorKind", "name": "fake-authenticator-name", }, - "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "issuedClientCert": map[string]any{ + "notBefore": "2024-09-12T04:20:56Z", // this is fakeNow - 5 minutes in UTC + "notAfter": "2024-09-12T04:30:56Z", // this is fakeNow + 5 minutes in UTC + }, "personalInfo": map[string]any{ "username": "test-user", "groups": []any{}, @@ -449,7 +457,7 @@ func TestCreate(t *testing.T) { requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()). Return(&user.DefaultInfo{Name: "test-user"}, nil) - storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{}, auditLogger, frozenClock) + storage := NewREST(requestAuthenticator, successfulIssuer(ctrl, fakeNow), schema.GroupResource{}, auditLogger) fakeReqContext := audit.WithAuditContext(context.Background()) audit.WithAuditID(fakeReqContext, "fake-audit-id") @@ -484,7 +492,10 @@ func TestCreate(t *testing.T) { "kind": "FakeAuthenticatorKind", "name": "fake-authenticator-name", }, - "issuedClientCertExpires": "2024-09-12T04:30:56Z", // this is frozenNow + 5 minutes in UTC + "issuedClientCert": map[string]any{ + "notBefore": "2024-09-12T04:20:56Z", // this is fakeNow - 5 minutes in UTC + "notAfter": "2024-09-12T04:30:56Z", // this is fakeNow + 5 minutes in UTC + }, "personalInfo": map[string]any{ "username": "test-user", "groups": []any{}, @@ -494,7 +505,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.NewContext(), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -507,7 +518,7 @@ func TestCreate(t *testing.T) { }) it("CreateFailsWhenNamespaceIsNotEmpty", func() { - response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger, frozenClock).Create( + response, err := NewREST(nil, nil, schema.GroupResource{}, auditLogger).Create( genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), validCredentialRequest(), rest.ValidateAllObjectFunc, @@ -576,10 +587,15 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err }) } -func successfulIssuer(ctrl *gomock.Controller) clientcertissuer.ClientCertIssuer { +func successfulIssuer(ctrl *gomock.Controller, fakeNow time.Time) clientcertissuer.ClientCertIssuer { clientCertIssuer := mockissuer.NewMockClientCertIssuer(ctrl) clientCertIssuer.EXPECT(). IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). - Return([]byte("test-cert"), []byte("test-key"), nil) + Return(&cert.PEM{ + CertPEM: []byte("test-cert"), + KeyPEM: []byte("test-key"), + NotBefore: fakeNow.Add(-5 * time.Minute), + NotAfter: fakeNow.Add(5 * time.Minute), + }, nil) return clientCertIssuer } diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index 93763d5d4..c3ab8c2ed 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -499,7 +499,10 @@ the Kubernetes audit logs for the same request, allowing them to be correlated. "username": "pinny@example.com", "groups": ["developers", "auditors"] }, - "issuedClientCertExpires": "2024-11-21T17:54:11Z", + "issuedClientCert": { + "notAfter": "2024-11-21T17:54:11Z", + "notBefore": "2024-11-21T17:44:11Z" + }, "authenticator": { "apiGroup": "authentication.concierge.pinniped.dev", "kind": "JWTAuthenticator", diff --git a/test/integration/audit_test.go b/test/integration/audit_test.go index 976142371..335d09fd2 100644 --- a/test/integration/audit_test.go +++ b/test/integration/audit_test.go @@ -190,10 +190,10 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { timeBeforeLogin, ) removeSomeKeysFromEachAuditLogEvent(allConciergeTCRLogs) - // Also remove issuedClientCertExpires, which is a timestamp that we can't easily predict for the assertions below. + // Also remove issuedClientCert, which contains timestamps that we can't easily predict for the assertions below. for _, log := range allConciergeTCRLogs { - require.NotEmpty(t, log["issuedClientCertExpires"]) - delete(log, "issuedClientCertExpires") + require.NotEmpty(t, log["issuedClientCert"]) + delete(log, "issuedClientCert") } // All values in the personalInfo map should be redacted by default. @@ -338,10 +338,10 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) { timeBeforeLogin, ) removeSomeKeysFromEachAuditLogEvent(allConciergeTCRLogs) - // Also remove issuedClientCertExpires, which is a timestamp that we can't easily predict for the assertions below. + // Also remove issuedClientCert, which contains timestamps that we can't easily predict for the assertions below. for _, log := range allConciergeTCRLogs { - require.NotEmpty(t, log["issuedClientCertExpires"]) - delete(log, "issuedClientCertExpires") + require.NotEmpty(t, log["issuedClientCert"]) + delete(log, "issuedClientCert") } // All values in the personalInfo map should not be redacted anymore. diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 96df0343c..4ea8525fc 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -1783,8 +1783,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl externallyProvidedCA, err = certauthority.New("Impersonation Proxy Integration Test CA", 1*time.Hour) require.NoError(t, err) - var externallyProvidedTLSServingCertPEM, externallyProvidedTLSServingKeyPEM []byte - externallyProvidedTLSServingCertPEM, externallyProvidedTLSServingKeyPEM, err = externallyProvidedCA.IssueServerCertPEM([]string{proxyServiceEndpoint}, nil, 1*time.Hour) + externallyProvidedTLSServingCertPEM, err := externallyProvidedCA.IssueServerCertPEM([]string{proxyServiceEndpoint}, nil, 1*time.Hour) require.NoError(t, err) // Specifically use corev1.Secret.StringData @@ -1796,8 +1795,8 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl corev1.SecretTypeTLS, map[string]string{ "ca.crt": string(externallyProvidedCA.Bundle()), - corev1.TLSCertKey: string(externallyProvidedTLSServingCertPEM), - corev1.TLSPrivateKeyKey: string(externallyProvidedTLSServingKeyPEM), + corev1.TLSCertKey: string(externallyProvidedTLSServingCertPEM.CertPEM), + corev1.TLSPrivateKeyKey: string(externallyProvidedTLSServingCertPEM.KeyPEM), }) _, originalInternallyGeneratedCAPEM := performImpersonatorDiscoveryURL(ctx, t, env, adminConciergeClient) @@ -1855,8 +1854,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl externallyProvidedCA, err = certauthority.New("Impersonation Proxy Integration Test CA", 1*time.Hour) require.NoError(t, err) - var externallyProvidedTLSServingCertPEM, externallyProvidedTLSServingKeyPEM []byte - externallyProvidedTLSServingCertPEM, externallyProvidedTLSServingKeyPEM, err = externallyProvidedCA.IssueServerCertPEM([]string{proxyServiceEndpoint}, nil, 1*time.Hour) + externallyProvidedTLSServingCertPEM, err := externallyProvidedCA.IssueServerCertPEM([]string{proxyServiceEndpoint}, nil, 1*time.Hour) require.NoError(t, err) // Specifically use corev1.Secret.Data @@ -1868,8 +1866,8 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl corev1.SecretTypeTLS, map[string][]byte{ "ca.crt": externallyProvidedCA.Bundle(), - corev1.TLSCertKey: externallyProvidedTLSServingCertPEM, - corev1.TLSPrivateKeyKey: externallyProvidedTLSServingKeyPEM, + corev1.TLSCertKey: externallyProvidedTLSServingCertPEM.CertPEM, + corev1.TLSPrivateKeyKey: externallyProvidedTLSServingCertPEM.KeyPEM, }) _, originalInternallyGeneratedCAPEM := performImpersonatorDiscoveryURL(ctx, t, env, adminConciergeClient) From df017f9267950628f7eb2035bfabcc9a83d2facb Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 22 Nov 2024 12:42:35 -0800 Subject: [PATCH 71/71] attempt to fix a test flake seen sometimes in CI --- test/integration/formposthtml_test.go | 35 +++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/test/integration/formposthtml_test.go b/test/integration/formposthtml_test.go index bbfb8c05f..ddc6c814d 100644 --- a/test/integration/formposthtml_test.go +++ b/test/integration/formposthtml_test.go @@ -1,4 +1,4 @@ -// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -76,15 +76,16 @@ func TestFormPostHTML_Browser_Parallel(t *testing.T) { require.Equal(t, responseParams.Get("code"), actualCode) }) - t.Run("timeout", func(t *testing.T) { + t.Run("timeout followed by eventual success", func(t *testing.T) { browser := browsertest.OpenBrowser(t) // Serve the form_post template with successful parameters. responseParams := formpostRandomParams(t) formpostInitiate(t, browser, formpostTemplateServer(t, callbackURL, responseParams)) - // Sleep for longer than the two second timeout. - // During this sleep we are blocking the callback from returning. + // Sleep for longer than the two-second timeout hardcoded in form_post.js. + // During this sleep we are blocking the callback from returning because we + // have not yet called expectCallback(). time.Sleep(3 * time.Second) // Assert that the timeout fires and we see the manual instructions. @@ -92,7 +93,7 @@ func TestFormPostHTML_Browser_Parallel(t *testing.T) { require.Equal(t, responseParams.Get("code"), actualCode) // Now simulate the callback finally succeeding, in which case - // the manual instructions should disappear and we should see the success + // the manual instructions should disappear, and we should see the success // div instead. expectCallback(t, responseParams) formpostExpectSuccessState(t, browser) @@ -117,9 +118,7 @@ func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) return } - // Allow CORS requests. This will be needed for this test in the future if we change - // the Javascript code from using mode 'no-cors' to instead use mode 'cors'. At the - // moment it should be ignored by the browser. + // Allow CORS requests. w.Header().Set("Access-Control-Allow-Origin", "*") assert.NoError(t, r.ParseForm()) @@ -132,8 +131,9 @@ func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) } } - // Send the form parameters back on the results channel, giving up if the - // request context is cancelled (such as if the client disconnects). + // Send the form parameters back on the results channel, blocking until the test calls + // the function returned by formpostCallbackServer() to read this message, but also + // giving up if the request context is cancelled (such as if the client disconnects). select { case results <- postParams: case <-r.Context().Done(): @@ -235,9 +235,24 @@ func formpostExpectFavicon(t *testing.T, b *browsertest.Browser, expected string // loading animation to be shown. func formpostInitiate(t *testing.T, b *browsertest.Browser, url string) { t.Helper() + t.Logf("navigating to mock form_post template URL %s...", url) + navigationStartTime := time.Now() b.Navigate(t, url) + // There is a race here, because the JS code will only show this loading animation + // for two seconds, and then will automatically hide it and instead show the manual + // copy/paste UI. So if this test runs on a very busy/slow machine that takes more + // than two seconds to start waiting for the loading div after opening the page, + // then it would fail. This is rare but does happen occasionally, so just skip these + // assertions in that case. + if time.Since(navigationStartTime) > 1500*time.Millisecond { + // Took too long to navigate to the page to be able to consistently see the + // loading animation, which is only supposed to last for 2 seconds. + t.Logf("skipping loading animation assertions because test was too slow...") + return + } + t.Logf("expecting to see loading animation...") b.WaitForVisibleElements(t, "div#loading") require.Equal(t, "Logging in...", b.Title(t))