Skip to content

Commit c057341

Browse files
kleshKlesh Wong
andauthored
feat(push-api): implement push API with authentication and validation logic (#8879)
Co-authored-by: Klesh Wong <kleshwong@gmail.com>
1 parent f03b83d commit c057341

8 files changed

Lines changed: 264 additions & 24 deletions

File tree

backend/server/api/api.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,11 @@ func CreateApiServer() *gin.Engine {
106106
router.GET("/version", version.Get)
107107

108108
// Auth chain order matters: REST API key first (its own short-circuit),
109-
// then OIDC session, then oauth2-proxy header (only sets USER if not yet
110-
// set), then the terminal 401 gate, finally CSRF on unsafe methods.
109+
// then the push API key gate, then OIDC session, then oauth2-proxy header
110+
// (only sets USER if not yet set), then the terminal 401 gate, finally
111+
// CSRF on unsafe methods.
111112
router.Use(RestAuthentication(router, basicRes))
113+
router.Use(RequirePushAuthentication(basicRes))
112114
router.Use(auth.OIDCAuthentication())
113115
router.Use(OAuth2ProxyAuthentication(basicRes))
114116
router.Use(auth.RequireAuth())

backend/server/api/middlewares.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ package api
2020
import (
2121
"encoding/base64"
2222
"fmt"
23-
"github.com/apache/incubator-devlake/core/log"
2423
"net/http"
2524
"regexp"
2625
"strings"
2726
"time"
2827

28+
"github.com/apache/incubator-devlake/core/log"
29+
2930
"github.com/apache/incubator-devlake/core/context"
3031
"github.com/apache/incubator-devlake/core/dal"
3132
"github.com/apache/incubator-devlake/core/errors"
@@ -113,7 +114,6 @@ func RestAuthentication(router *gin.Engine, basicRes context.BasicRes) gin.Handl
113114
path := c.Request.URL.Path
114115
// Only open api needs to check api key
115116
if !strings.HasPrefix(path, "/rest") {
116-
logger.Debug("path %s will continue", path)
117117
c.Next()
118118
return
119119
}
@@ -131,6 +131,53 @@ func RestAuthentication(router *gin.Engine, basicRes context.BasicRes) gin.Handl
131131
}
132132
}
133133

134+
func RequirePushAuthentication(basicRes context.BasicRes) gin.HandlerFunc {
135+
logger := basicRes.GetLogger()
136+
return func(c *gin.Context) {
137+
path := c.Request.URL.Path
138+
if !strings.HasPrefix(path, "/push/") {
139+
c.Next()
140+
return
141+
}
142+
143+
authHeader := c.GetHeader("Authorization")
144+
if authHeader == "" {
145+
c.Abort()
146+
c.JSON(http.StatusUnauthorized, &apiBody{
147+
Success: false,
148+
Message: "token is missing",
149+
})
150+
return
151+
}
152+
apiKeyStr := strings.TrimPrefix(authHeader, "Bearer ")
153+
if apiKeyStr == authHeader || apiKeyStr == "" {
154+
c.Abort()
155+
c.JSON(http.StatusUnauthorized, &apiBody{
156+
Success: false,
157+
Message: "token is not present or malformed",
158+
})
159+
return
160+
}
161+
162+
db := basicRes.GetDal()
163+
if db == nil {
164+
logger.Error(nil, "db is not initialised")
165+
c.Abort()
166+
c.JSON(http.StatusInternalServerError, &apiBody{
167+
Success: false,
168+
Message: "database is not initialised",
169+
})
170+
return
171+
}
172+
173+
apiKeyHelper := apikeyhelper.NewApiKeyHelper(basicRes, logger)
174+
if !CheckAuthorizationHeader(c, logger, db, apiKeyHelper, authHeader, path) {
175+
return
176+
}
177+
c.Next()
178+
}
179+
}
180+
134181
func CheckAuthorizationHeader(c *gin.Context, logger log.Logger, db dal.Dal, apiKeyHelper *apikeyhelper.ApiKeyHelper, authHeader, path string) bool {
135182
if authHeader == "" {
136183
c.Abort()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"net/http"
22+
"net/http/httptest"
23+
"strings"
24+
"testing"
25+
26+
corectx "github.com/apache/incubator-devlake/core/context"
27+
contextimpl "github.com/apache/incubator-devlake/impls/context"
28+
"github.com/apache/incubator-devlake/impls/logruslog"
29+
"github.com/gin-gonic/gin"
30+
"github.com/spf13/viper"
31+
)
32+
33+
func newPushTestBasicRes() corectx.BasicRes {
34+
cfg := viper.New()
35+
cfg.Set("ENCRYPTION_SECRET", strings.Repeat("a", 32))
36+
return contextimpl.NewDefaultBasicRes(cfg, logruslog.Global, nil)
37+
}
38+
39+
func TestRequirePushAuthenticationRejectsMissingToken(t *testing.T) {
40+
gin.SetMode(gin.TestMode)
41+
router := gin.New()
42+
router.Use(RequirePushAuthentication(newPushTestBasicRes()))
43+
router.POST("/push/:tableName", func(c *gin.Context) {
44+
c.Status(http.StatusOK)
45+
})
46+
47+
req := httptest.NewRequest(http.MethodPost, "/push/commits", strings.NewReader(`[{}]`))
48+
req.Header.Set("Content-Type", "application/json")
49+
resp := httptest.NewRecorder()
50+
router.ServeHTTP(resp, req)
51+
52+
if resp.Code != http.StatusUnauthorized {
53+
t.Fatalf("status = %d, want %d", resp.Code, http.StatusUnauthorized)
54+
}
55+
}
56+
57+
func TestRequirePushAuthenticationRejectsMalformedToken(t *testing.T) {
58+
gin.SetMode(gin.TestMode)
59+
router := gin.New()
60+
router.Use(RequirePushAuthentication(newPushTestBasicRes()))
61+
router.POST("/push/:tableName", func(c *gin.Context) {
62+
c.Status(http.StatusOK)
63+
})
64+
65+
req := httptest.NewRequest(http.MethodPost, "/push/commits", strings.NewReader(`[{}]`))
66+
req.Header.Set("Content-Type", "application/json")
67+
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
68+
resp := httptest.NewRecorder()
69+
router.ServeHTTP(resp, req)
70+
71+
if resp.Code != http.StatusUnauthorized {
72+
t.Fatalf("status = %d, want %d", resp.Code, http.StatusUnauthorized)
73+
}
74+
}

backend/server/api/push/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,20 @@ limitations under the License.
2121
This is a generic API service that gives our users the ability to inject data directly to their own database using a
2222
simple, all-purpose endpoint.
2323

24+
The push API is disabled by default. To enable it safely, you must:
25+
- configure `PUSH_API_ALLOWED_TABLES` with the specific non-internal tables that may be written
26+
- send a Bearer API key whose `allowedPath` matches the `/push/...` endpoint you are calling
27+
2428
## The Endpoint
2529

2630
POST to ```localhost:8080/push/:tableName```
2731

2832
Where "tableName" is the name of the table you wish to insert into
2933
For example, "commits" would be ```/push/commits```
3034

35+
Internal `_devlake_*` tables are never writable through this endpoint, even if
36+
they are listed in `PUSH_API_ALLOWED_TABLES`.
37+
3138
## The JSON body
3239

3340
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
4552
```
4653

4754

48-

backend/server/services/pushapi.go

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,15 @@ limitations under the License.
1818
package services
1919

2020
import (
21-
"regexp"
22-
"strings"
23-
2421
"github.com/apache/incubator-devlake/core/dal"
2522
"github.com/apache/incubator-devlake/core/errors"
23+
"github.com/apache/incubator-devlake/server/services/pushapiaccess"
2624
)
2725

2826
// InsertRow FIXME ...
2927
func InsertRow(table string, rows []map[string]interface{}) (int64, errors.Error) {
30-
if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(table) {
31-
return 0, errors.BadInput.New("table name invalid")
32-
}
33-
34-
if allowedTables := cfg.GetString("PUSH_API_ALLOWED_TABLES"); allowedTables != "" {
35-
allow := false
36-
for _, t := range strings.Split(allowedTables, ",") {
37-
if strings.TrimSpace(t) == table {
38-
allow = true
39-
break
40-
}
41-
}
42-
if !allow {
43-
return 0, errors.Forbidden.New("table name is not in the allowed list")
44-
}
28+
if err := pushapiaccess.ValidateTable(table, cfg.GetString("PUSH_API_ALLOWED_TABLES")); err != nil {
29+
return 0, err
4530
}
4631

4732
err := db.Create(rows, dal.From(table))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package pushapiaccess
19+
20+
import (
21+
"regexp"
22+
"strings"
23+
24+
"github.com/apache/incubator-devlake/core/errors"
25+
)
26+
27+
var tableNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
28+
29+
const internalTablePrefix = "_devlake_"
30+
31+
func ValidateTable(table string, allowedTables string) errors.Error {
32+
if !tableNameRegex.MatchString(table) {
33+
return errors.BadInput.New("table name invalid")
34+
}
35+
if strings.HasPrefix(table, internalTablePrefix) {
36+
return errors.Forbidden.New("writing internal tables via push API is forbidden")
37+
}
38+
39+
allowlist := map[string]struct{}{}
40+
for _, t := range strings.Split(allowedTables, ",") {
41+
name := strings.TrimSpace(t)
42+
if name == "" || strings.HasPrefix(name, internalTablePrefix) {
43+
continue
44+
}
45+
allowlist[name] = struct{}{}
46+
}
47+
if len(allowlist) == 0 {
48+
return errors.Forbidden.New("push API is disabled unless PUSH_API_ALLOWED_TABLES is configured")
49+
}
50+
if _, ok := allowlist[table]; !ok {
51+
return errors.Forbidden.New("table name is not in the allowed list")
52+
}
53+
return nil
54+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package pushapiaccess
19+
20+
import "testing"
21+
22+
func TestValidateTable(t *testing.T) {
23+
cases := []struct {
24+
name string
25+
table string
26+
allowedTables string
27+
wantErr bool
28+
}{
29+
{
30+
name: "allows configured application table",
31+
table: "commits",
32+
allowedTables: "commits, issues",
33+
},
34+
{
35+
name: "rejects invalid table name",
36+
table: "commits;drop",
37+
allowedTables: "commits",
38+
wantErr: true,
39+
},
40+
{
41+
name: "default denies when allowlist unset",
42+
table: "commits",
43+
wantErr: true,
44+
},
45+
{
46+
name: "rejects internal tables even when allowlisted",
47+
table: "_devlake_pipelines",
48+
allowedTables: "_devlake_pipelines,commits",
49+
wantErr: true,
50+
},
51+
{
52+
name: "rejects tables missing from allowlist",
53+
table: "pull_requests",
54+
allowedTables: "commits,issues",
55+
wantErr: true,
56+
},
57+
}
58+
59+
for _, tc := range cases {
60+
t.Run(tc.name, func(t *testing.T) {
61+
err := ValidateTable(tc.table, tc.allowedTables)
62+
if tc.wantErr && err == nil {
63+
t.Fatal("expected an error but got nil")
64+
}
65+
if !tc.wantErr && err != nil {
66+
t.Fatalf("expected no error, got %v", err)
67+
}
68+
})
69+
}
70+
}

env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ SKIP_SUBTASK_PROGRESS=false
3434
PORT=8080
3535
MODE=release
3636

37-
# PUSH_API_ALLOWED_TABLES=table1,table2
37+
# Push API is disabled by default. To enable it, list only application tables
38+
# here. Internal _devlake_* tables are always forbidden.
39+
PUSH_API_ALLOWED_TABLES=
3840
NOTIFICATION_ENDPOINT=
3941
NOTIFICATION_SECRET=
4042

0 commit comments

Comments
 (0)