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

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


The following commit(s) were added to refs/heads/main by this push:
     new c057341d1 feat(push-api): implement push API with authentication and 
validation logic (#8879)
c057341d1 is described below

commit c057341d13cad9ac308581a1c9f49cfcfd49a846
Author: Klesh Wong <[email protected]>
AuthorDate: Sun May 17 17:15:16 2026 +0800

    feat(push-api): implement push API with authentication and validation logic 
(#8879)
    
    Co-authored-by: Klesh Wong <[email protected]>
---
 backend/server/api/api.go                          |  6 +-
 backend/server/api/middlewares.go                  | 51 ++++++++++++++-
 backend/server/api/middlewares_test.go             | 74 ++++++++++++++++++++++
 backend/server/api/push/README.md                  |  8 ++-
 backend/server/services/pushapi.go                 | 21 +-----
 backend/server/services/pushapiaccess/access.go    | 54 ++++++++++++++++
 .../server/services/pushapiaccess/access_test.go   | 70 ++++++++++++++++++++
 env.example                                        |  4 +-
 8 files changed, 264 insertions(+), 24 deletions(-)

diff --git a/backend/server/api/api.go b/backend/server/api/api.go
index 8834c4ecf..32ca49bd8 100644
--- a/backend/server/api/api.go
+++ b/backend/server/api/api.go
@@ -106,9 +106,11 @@ func CreateApiServer() *gin.Engine {
        router.GET("/version", version.Get)
 
        // Auth chain order matters: REST API key first (its own short-circuit),
-       // then OIDC session, then oauth2-proxy header (only sets USER if not 
yet
-       // set), then the terminal 401 gate, finally CSRF on unsafe methods.
+       // then the push API key gate, then OIDC session, then oauth2-proxy 
header
+       // (only sets USER if not yet set), then the terminal 401 gate, finally
+       // CSRF on unsafe methods.
        router.Use(RestAuthentication(router, basicRes))
+       router.Use(RequirePushAuthentication(basicRes))
        router.Use(auth.OIDCAuthentication())
        router.Use(OAuth2ProxyAuthentication(basicRes))
        router.Use(auth.RequireAuth())
diff --git a/backend/server/api/middlewares.go 
b/backend/server/api/middlewares.go
index 86c8c6a6f..b6e517847 100644
--- a/backend/server/api/middlewares.go
+++ b/backend/server/api/middlewares.go
@@ -20,12 +20,13 @@ package api
 import (
        "encoding/base64"
        "fmt"
-       "github.com/apache/incubator-devlake/core/log"
        "net/http"
        "regexp"
        "strings"
        "time"
 
+       "github.com/apache/incubator-devlake/core/log"
+
        "github.com/apache/incubator-devlake/core/context"
        "github.com/apache/incubator-devlake/core/dal"
        "github.com/apache/incubator-devlake/core/errors"
@@ -113,7 +114,6 @@ func RestAuthentication(router *gin.Engine, basicRes 
context.BasicRes) gin.Handl
                path := c.Request.URL.Path
                // Only open api needs to check api key
                if !strings.HasPrefix(path, "/rest") {
-                       logger.Debug("path %s will continue", path)
                        c.Next()
                        return
                }
@@ -131,6 +131,53 @@ func RestAuthentication(router *gin.Engine, basicRes 
context.BasicRes) gin.Handl
        }
 }
 
+func RequirePushAuthentication(basicRes context.BasicRes) gin.HandlerFunc {
+       logger := basicRes.GetLogger()
+       return func(c *gin.Context) {
+               path := c.Request.URL.Path
+               if !strings.HasPrefix(path, "/push/") {
+                       c.Next()
+                       return
+               }
+
+               authHeader := c.GetHeader("Authorization")
+               if authHeader == "" {
+                       c.Abort()
+                       c.JSON(http.StatusUnauthorized, &apiBody{
+                               Success: false,
+                               Message: "token is missing",
+                       })
+                       return
+               }
+               apiKeyStr := strings.TrimPrefix(authHeader, "Bearer ")
+               if apiKeyStr == authHeader || apiKeyStr == "" {
+                       c.Abort()
+                       c.JSON(http.StatusUnauthorized, &apiBody{
+                               Success: false,
+                               Message: "token is not present or malformed",
+                       })
+                       return
+               }
+
+               db := basicRes.GetDal()
+               if db == nil {
+                       logger.Error(nil, "db is not initialised")
+                       c.Abort()
+                       c.JSON(http.StatusInternalServerError, &apiBody{
+                               Success: false,
+                               Message: "database is not initialised",
+                       })
+                       return
+               }
+
+               apiKeyHelper := apikeyhelper.NewApiKeyHelper(basicRes, logger)
+               if !CheckAuthorizationHeader(c, logger, db, apiKeyHelper, 
authHeader, path) {
+                       return
+               }
+               c.Next()
+       }
+}
+
 func CheckAuthorizationHeader(c *gin.Context, logger log.Logger, db dal.Dal, 
apiKeyHelper *apikeyhelper.ApiKeyHelper, authHeader, path string) bool {
        if authHeader == "" {
                c.Abort()
diff --git a/backend/server/api/middlewares_test.go 
b/backend/server/api/middlewares_test.go
new file mode 100644
index 000000000..bb6d1f268
--- /dev/null
+++ b/backend/server/api/middlewares_test.go
@@ -0,0 +1,74 @@
+/*
+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"
+       "strings"
+       "testing"
+
+       corectx "github.com/apache/incubator-devlake/core/context"
+       contextimpl "github.com/apache/incubator-devlake/impls/context"
+       "github.com/apache/incubator-devlake/impls/logruslog"
+       "github.com/gin-gonic/gin"
+       "github.com/spf13/viper"
+)
+
+func newPushTestBasicRes() corectx.BasicRes {
+       cfg := viper.New()
+       cfg.Set("ENCRYPTION_SECRET", strings.Repeat("a", 32))
+       return contextimpl.NewDefaultBasicRes(cfg, logruslog.Global, nil)
+}
+
+func TestRequirePushAuthenticationRejectsMissingToken(t *testing.T) {
+       gin.SetMode(gin.TestMode)
+       router := gin.New()
+       router.Use(RequirePushAuthentication(newPushTestBasicRes()))
+       router.POST("/push/:tableName", func(c *gin.Context) {
+               c.Status(http.StatusOK)
+       })
+
+       req := httptest.NewRequest(http.MethodPost, "/push/commits", 
strings.NewReader(`[{}]`))
+       req.Header.Set("Content-Type", "application/json")
+       resp := httptest.NewRecorder()
+       router.ServeHTTP(resp, req)
+
+       if resp.Code != http.StatusUnauthorized {
+               t.Fatalf("status = %d, want %d", resp.Code, 
http.StatusUnauthorized)
+       }
+}
+
+func TestRequirePushAuthenticationRejectsMalformedToken(t *testing.T) {
+       gin.SetMode(gin.TestMode)
+       router := gin.New()
+       router.Use(RequirePushAuthentication(newPushTestBasicRes()))
+       router.POST("/push/:tableName", func(c *gin.Context) {
+               c.Status(http.StatusOK)
+       })
+
+       req := httptest.NewRequest(http.MethodPost, "/push/commits", 
strings.NewReader(`[{}]`))
+       req.Header.Set("Content-Type", "application/json")
+       req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
+       resp := httptest.NewRecorder()
+       router.ServeHTTP(resp, req)
+
+       if resp.Code != http.StatusUnauthorized {
+               t.Fatalf("status = %d, want %d", resp.Code, 
http.StatusUnauthorized)
+       }
+}
diff --git a/backend/server/api/push/README.md 
b/backend/server/api/push/README.md
index 999f087b4..f9aa3cb8e 100644
--- a/backend/server/api/push/README.md
+++ b/backend/server/api/push/README.md
@@ -21,6 +21,10 @@ limitations under the License.
 This is a generic API service that gives our users the ability to inject data 
directly to their own database using a
 simple, all-purpose endpoint.
 
+The push API is disabled by default. To enable it safely, you must:
+- configure `PUSH_API_ALLOWED_TABLES` with the specific non-internal tables 
that may be written
+- send a Bearer API key whose `allowedPath` matches the `/push/...` endpoint 
you are calling
+
 ## The Endpoint
 
 POST to ```localhost:8080/push/:tableName```
@@ -28,6 +32,9 @@ POST to ```localhost:8080/push/:tableName```
 Where "tableName" is the name of the table you wish to insert into
 For example, "commits" would be ```/push/commits```
 
+Internal `_devlake_*` tables are never writable through this endpoint, even if
+they are listed in `PUSH_API_ALLOWED_TABLES`.
+
 ## The JSON body
 
 Include a JSON body that consists of an array of objects you wish to insert.
@@ -45,4 +52,3 @@ Please Note: You must know the schema you are inserting into 
(column names, type
 ```
 
 
-
diff --git a/backend/server/services/pushapi.go 
b/backend/server/services/pushapi.go
index d99d4e906..7882b0aa1 100644
--- a/backend/server/services/pushapi.go
+++ b/backend/server/services/pushapi.go
@@ -18,30 +18,15 @@ limitations under the License.
 package services
 
 import (
-       "regexp"
-       "strings"
-
        "github.com/apache/incubator-devlake/core/dal"
        "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/server/services/pushapiaccess"
 )
 
 // InsertRow FIXME ...
 func InsertRow(table string, rows []map[string]interface{}) (int64, 
errors.Error) {
-       if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(table) {
-               return 0, errors.BadInput.New("table name invalid")
-       }
-
-       if allowedTables := cfg.GetString("PUSH_API_ALLOWED_TABLES"); 
allowedTables != "" {
-               allow := false
-               for _, t := range strings.Split(allowedTables, ",") {
-                       if strings.TrimSpace(t) == table {
-                               allow = true
-                               break
-                       }
-               }
-               if !allow {
-                       return 0, errors.Forbidden.New("table name is not in 
the allowed list")
-               }
+       if err := pushapiaccess.ValidateTable(table, 
cfg.GetString("PUSH_API_ALLOWED_TABLES")); err != nil {
+               return 0, err
        }
 
        err := db.Create(rows, dal.From(table))
diff --git a/backend/server/services/pushapiaccess/access.go 
b/backend/server/services/pushapiaccess/access.go
new file mode 100644
index 000000000..40f213be1
--- /dev/null
+++ b/backend/server/services/pushapiaccess/access.go
@@ -0,0 +1,54 @@
+/*
+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 pushapiaccess
+
+import (
+       "regexp"
+       "strings"
+
+       "github.com/apache/incubator-devlake/core/errors"
+)
+
+var tableNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
+
+const internalTablePrefix = "_devlake_"
+
+func ValidateTable(table string, allowedTables string) errors.Error {
+       if !tableNameRegex.MatchString(table) {
+               return errors.BadInput.New("table name invalid")
+       }
+       if strings.HasPrefix(table, internalTablePrefix) {
+               return errors.Forbidden.New("writing internal tables via push 
API is forbidden")
+       }
+
+       allowlist := map[string]struct{}{}
+       for _, t := range strings.Split(allowedTables, ",") {
+               name := strings.TrimSpace(t)
+               if name == "" || strings.HasPrefix(name, internalTablePrefix) {
+                       continue
+               }
+               allowlist[name] = struct{}{}
+       }
+       if len(allowlist) == 0 {
+               return errors.Forbidden.New("push API is disabled unless 
PUSH_API_ALLOWED_TABLES is configured")
+       }
+       if _, ok := allowlist[table]; !ok {
+               return errors.Forbidden.New("table name is not in the allowed 
list")
+       }
+       return nil
+}
diff --git a/backend/server/services/pushapiaccess/access_test.go 
b/backend/server/services/pushapiaccess/access_test.go
new file mode 100644
index 000000000..969a54365
--- /dev/null
+++ b/backend/server/services/pushapiaccess/access_test.go
@@ -0,0 +1,70 @@
+/*
+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 pushapiaccess
+
+import "testing"
+
+func TestValidateTable(t *testing.T) {
+       cases := []struct {
+               name          string
+               table         string
+               allowedTables string
+               wantErr       bool
+       }{
+               {
+                       name:          "allows configured application table",
+                       table:         "commits",
+                       allowedTables: "commits, issues",
+               },
+               {
+                       name:          "rejects invalid table name",
+                       table:         "commits;drop",
+                       allowedTables: "commits",
+                       wantErr:       true,
+               },
+               {
+                       name:    "default denies when allowlist unset",
+                       table:   "commits",
+                       wantErr: true,
+               },
+               {
+                       name:          "rejects internal tables even when 
allowlisted",
+                       table:         "_devlake_pipelines",
+                       allowedTables: "_devlake_pipelines,commits",
+                       wantErr:       true,
+               },
+               {
+                       name:          "rejects tables missing from allowlist",
+                       table:         "pull_requests",
+                       allowedTables: "commits,issues",
+                       wantErr:       true,
+               },
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := ValidateTable(tc.table, tc.allowedTables)
+                       if tc.wantErr && err == nil {
+                               t.Fatal("expected an error but got nil")
+                       }
+                       if !tc.wantErr && err != nil {
+                               t.Fatalf("expected no error, got %v", err)
+                       }
+               })
+       }
+}
diff --git a/env.example b/env.example
index 64fa6b39f..460d01b24 100755
--- a/env.example
+++ b/env.example
@@ -34,7 +34,9 @@ SKIP_SUBTASK_PROGRESS=false
 PORT=8080
 MODE=release
 
-# PUSH_API_ALLOWED_TABLES=table1,table2
+# Push API is disabled by default. To enable it, list only application tables
+# here. Internal _devlake_* tables are always forbidden.
+PUSH_API_ALLOWED_TABLES=
 NOTIFICATION_ENDPOINT=
 NOTIFICATION_SECRET=
 

Reply via email to