This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 20179fa52 Fix webhook authorization when AUTH_ENABLED=true (#9008)
20179fa52 is described below

commit 20179fa520d03df65dd58d7295421a3d832fe0d5
Author: Christophe Kamphaus <[email protected]>
AuthorDate: Mon Jul 27 08:14:21 2026 +0200

    Fix webhook authorization when AUTH_ENABLED=true (#9008)
    
    * test(webhook): add test for webhook api key with activated auth
    
    * fix(webhook): fix webhook authorization
---
 backend/server/api/middlewares.go                |   9 +-
 backend/server/api/middlewares_rest_auth_test.go | 107 +++++++++++++++++++++++
 backend/server/api/shared/gin_utils.go           |  29 +++++-
 3 files changed, 139 insertions(+), 6 deletions(-)

diff --git a/backend/server/api/middlewares.go 
b/backend/server/api/middlewares.go
index a988c4128..e1609b1ec 100644
--- a/backend/server/api/middlewares.go
+++ b/backend/server/api/middlewares.go
@@ -33,6 +33,7 @@ import (
        "github.com/apache/incubator-devlake/core/errors"
        "github.com/apache/incubator-devlake/core/models/common"
        "github.com/apache/incubator-devlake/helpers/apikeyhelper"
+       "github.com/apache/incubator-devlake/server/api/shared"
        "github.com/gin-gonic/gin"
 )
 
@@ -272,9 +273,13 @@ func CheckAuthorizationHeader(c *gin.Context, logger 
log.Logger, db dal.Dal, api
 
        logger.Info("redirect path: %s to: %s", c.Request.URL.Path, path)
        c.Request.URL.Path = path
-       c.Set(common.USER, &common.User{
+       user := &common.User{
                Name:  apiKey.Creator.Creator,
                Email: apiKey.Creator.CreatorEmail,
-       })
+       }
+       c.Set(common.USER, user)
+       // Also store in the request context so the user survives gin's 
HandleContext
+       // resetting c.Keys when rerouting from /rest/... to /plugins/...
+       c.Request = shared.SetRestAuthUser(c.Request, user)
        return true
 }
diff --git a/backend/server/api/middlewares_rest_auth_test.go 
b/backend/server/api/middlewares_rest_auth_test.go
new file mode 100644
index 000000000..41d421b65
--- /dev/null
+++ b/backend/server/api/middlewares_rest_auth_test.go
@@ -0,0 +1,107 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+       "net/http"
+       "net/http/httptest"
+       "testing"
+
+       "github.com/apache/incubator-devlake/core/config"
+       coremodels "github.com/apache/incubator-devlake/core/models"
+       "github.com/apache/incubator-devlake/core/models/common"
+       "github.com/apache/incubator-devlake/helpers/apikeyhelper"
+       contextimpl "github.com/apache/incubator-devlake/impls/context"
+       "github.com/apache/incubator-devlake/impls/logruslog"
+       mockdal "github.com/apache/incubator-devlake/mocks/core/dal"
+       "github.com/apache/incubator-devlake/server/api/shared"
+       "github.com/gin-gonic/gin"
+       "github.com/stretchr/testify/mock"
+)
+
+// requireUserGate simulates RequireAuth with AUTH_ENABLED=true: it rejects any
+// request whose gin context does not carry an authenticated user. This is the
+// exact check that caused 401s for valid REST API key requests.
+func requireUserGate() gin.HandlerFunc {
+       return func(c *gin.Context) {
+               if _, ok := shared.GetUser(c); !ok {
+                       c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+                               "success": false,
+                               "message": "unauthorized",
+                       })
+                       return
+               }
+               c.Next()
+       }
+}
+
+// TestRestAuthKeyReachesHandlerWhenAuthEnabled sends a valid /rest/... Bearer
+// token request through a router that also has a RequireAuth-style gate. The
+// request must reach the downstream handler (200) rather than being rejected
+// by the auth gate (401).
+//
+// Without the fix, gin's HandleContext resets c.Keys, wiping the user that
+// RestAuthentication stored before rerouting, so the auth gate sees no user
+// and returns 401.
+func TestRestAuthKeyReachesHandlerWhenAuthEnabled(t *testing.T) {
+       gin.SetMode(gin.TestMode)
+
+       // apikeyhelper reads ENCRYPTION_SECRET from the global viper config.
+       const encryptionSecret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 32 bytes
+       config.GetConfig().Set("ENCRYPTION_SECRET", encryptionSecret)
+
+       basicRes := contextimpl.NewDefaultBasicRes(config.GetConfig(), 
logruslog.Global, nil)
+       helper := apikeyhelper.NewApiKeyHelper(basicRes, logruslog.Global)
+
+       const plaintext = "test-api-key-plaintext"
+       hashedKey, hashErr := helper.DigestToken(plaintext)
+       if hashErr != nil {
+               t.Fatalf("DigestToken: %v", hashErr)
+       }
+
+       // Mock DAL: when First is called, populate the destination with a 
valid key
+       // whose AllowedPath covers the webhook endpoint under test.
+       db := &mockdal.Dal{}
+       db.On("First", mock.Anything, mock.Anything).
+               Run(func(args mock.Arguments) {
+                       dst := args.Get(0).(*coremodels.ApiKey)
+                       dst.ApiKey = hashedKey
+                       dst.AllowedPath = `/plugins/webhook/connections/1/.*`
+                       dst.Creator = common.Creator{Creator: "test-user"}
+               }).
+               Return(nil)
+
+       basicRes = contextimpl.NewDefaultBasicRes(config.GetConfig(), 
logruslog.Global, db)
+
+       router := gin.New()
+       router.Use(RestAuthentication(router, basicRes))
+       router.Use(requireUserGate())
+       router.POST("/plugins/webhook/connections/:id/deployments", func(c 
*gin.Context) {
+               c.Status(http.StatusOK)
+       })
+
+       req := httptest.NewRequest(http.MethodPost, 
"/rest/plugins/webhook/connections/1/deployments", nil)
+       req.Header.Set("Authorization", "Bearer "+plaintext)
+       resp := httptest.NewRecorder()
+       router.ServeHTTP(resp, req)
+
+       if resp.Code != http.StatusOK {
+               t.Fatalf("expected 200 with valid REST API key when auth gate 
is active, got %d: %s",
+                       resp.Code, resp.Body.String())
+       }
+}
diff --git a/backend/server/api/shared/gin_utils.go 
b/backend/server/api/shared/gin_utils.go
index 892dc489a..c15d0a730 100644
--- a/backend/server/api/shared/gin_utils.go
+++ b/backend/server/api/shared/gin_utils.go
@@ -18,15 +18,36 @@ limitations under the License.
 package shared
 
 import (
+       "context"
+       "net/http"
+
        "github.com/apache/incubator-devlake/core/models/common"
        "github.com/gin-gonic/gin"
 )
 
+// restAuthKey is an unexported type used as a request-context key so it cannot
+// collide with keys set by other packages.
+type restAuthKey struct{}
+
+// SetRestAuthUser stores the authenticated user in the HTTP request context.
+// This is necessary because gin's HandleContext calls c.reset(), which clears
+// c.Keys but leaves c.Request (and its context) intact. RestAuthentication
+// calls this before rerouting so the user survives the reset.
+func SetRestAuthUser(r *http.Request, user *common.User) *http.Request {
+       return r.WithContext(context.WithValue(r.Context(), restAuthKey{}, 
user))
+}
+
 func GetUser(c *gin.Context) (*common.User, bool) {
        userObj, exist := c.Get(common.USER)
-       if !exist {
-               return nil, false
+       if exist {
+               if user, ok := userObj.(*common.User); ok {
+                       return user, true
+               }
+       }
+       // Fallback: RestAuthentication stores the user here before calling
+       // HandleContext, which resets c.Keys but preserves c.Request.
+       if user, ok := c.Request.Context().Value(restAuthKey{}).(*common.User); 
ok && user != nil {
+               return user, true
        }
-       user := userObj.(*common.User)
-       return user, true
+       return nil, false
 }

Reply via email to