Skip to content

Commit 5a336b8

Browse files
committed
ING-1339: Add mtls support for Data API proxy
1 parent d3f68a1 commit 5a336b8

7 files changed

Lines changed: 276 additions & 10 deletions

File tree

.github/workflows/client_cert_auth_test.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ jobs:
1616
strategy:
1717
matrix:
1818
server:
19-
- 8.0.0-3534
20-
- 7.6.5
21-
- 7.2.2
19+
- 8.1.0-1203
20+
- 8.0.0
21+
- 7.6.8
22+
- 7.2.8
2223

2324
runs-on: ubuntu-latest
2425
steps:
@@ -53,7 +54,7 @@ jobs:
5354
env:
5455
SGTEST_CBCONNSTR: ${{ steps.start-cluster.outputs.node-ip }}
5556
SGTEST_DINOID: ${{ steps.start-cluster.outputs.dino-id }}
56-
run: go test ./gateway/test -run TestGatewayOps -v -testify.m TestClientCertAuth
57+
run: go test ./gateway/test -run TestGatewayOps -v -testify.m ClientCertAuth
5758

5859
- name: Collect couchbase logs
5960
timeout-minutes: 10

gateway/auth/cbauthauthenticator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (a *CbAuthAuthenticator) ValidateConnStateForObo(ctx context.Context, connS
103103
return "", "", ErrInvalidCertificate
104104
}
105105

106-
return "", "", fmt.Errorf("failed to check certificate with cbauth: %s", err.Error())
106+
return "", "", fmt.Errorf("failed to check certificate with cbauth: %w", err)
107107
}
108108

109109
return info.User, info.Domain, nil

gateway/dapiimpl/dapiimpl.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ type NewOptions struct {
1818
ProxyServices []proxy.ServiceType
1919
ProxyBlockAdmin bool
2020
Debug bool
21+
22+
Username string
23+
Password string
2124
}
2225

2326
type Servers struct {
@@ -44,7 +47,11 @@ func New(opts *NewOptions) *Servers {
4447
opts.CbClient,
4548
opts.ProxyServices,
4649
opts.ProxyBlockAdmin,
47-
opts.Debug),
50+
opts.Debug,
51+
v1AuthHandler,
52+
opts.Username,
53+
opts.Password,
54+
),
4855
DataApiV1Server: server_v1.NewDataApiServer(
4956
opts.Logger.Named("dapi-serverv1"),
5057
v1ErrHandler,

gateway/dapiimpl/proxy/proxy.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package proxy
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"errors"
57
"fmt"
68
"io"
79
"net/http"
@@ -16,7 +18,10 @@ import (
1618
"go.opentelemetry.io/otel/propagation"
1719

1820
"github.com/couchbase/gocbcorex"
21+
"github.com/couchbase/gocbcorex/cbauthx"
1922
"github.com/couchbase/gocbcorex/contrib/buildversion"
23+
"github.com/couchbase/stellar-gateway/gateway/auth"
24+
"github.com/couchbase/stellar-gateway/gateway/dapiimpl/server_v1"
2025
"go.opentelemetry.io/otel"
2126
"go.opentelemetry.io/otel/attribute"
2227
"go.opentelemetry.io/otel/metric"
@@ -45,12 +50,16 @@ type DataApiProxy struct {
4550
cbClient *gocbcorex.BucketsTrackingAgentManager
4651
disableAdmin bool
4752
debugMode bool
48-
mux *http.ServeMux
53+
mux http.Handler
4954

5055
numFailures metric.Int64Counter
5156
numRequests metric.Int64Counter
5257
ttfbMillis metric.Int64Histogram
5358
durationMillis metric.Int64Histogram
59+
60+
username string
61+
password string
62+
authHander *server_v1.AuthHandler
5463
}
5564

5665
func NewDataApiProxy(
@@ -59,6 +68,8 @@ func NewDataApiProxy(
5968
services []ServiceType,
6069
disableAdmin bool,
6170
debugMode bool,
71+
authHandler *server_v1.AuthHandler,
72+
username, password string,
6273
) *DataApiProxy {
6374
mux := http.NewServeMux()
6475

@@ -92,6 +103,9 @@ func NewDataApiProxy(
92103
numRequests: numRequests,
93104
ttfbMillis: ttfbMillis,
94105
durationMillis: durationMillis,
106+
username: username,
107+
password: password,
108+
authHander: authHandler,
95109
}
96110

97111
for _, serviceName := range services {
@@ -127,9 +141,13 @@ func (p *DataApiProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
127141
}
128142

129143
func (p *DataApiProxy) writeError(w http.ResponseWriter, err error, msg string) {
144+
p.writeErrorWithStatus(w, err, msg, 502)
145+
}
146+
147+
func (p *DataApiProxy) writeErrorWithStatus(w http.ResponseWriter, err error, msg string, status int) {
130148
p.logger.Debug(msg, zap.Error(err))
131149

132-
w.WriteHeader(502)
150+
w.WriteHeader(status)
133151

134152
if !p.debugMode {
135153
_, _ = fmt.Fprintf(w, "%s", msg)
@@ -252,6 +270,33 @@ func (p *DataApiProxy) proxyService(
252270
// copy some other details
253271
proxyReq.Header = r.Header
254272

273+
// If no auth header has been given, check for a client cert
274+
authHdr := proxyReq.Header.Get("Authorization")
275+
if authHdr == "" {
276+
oboUser, oboDomain, err := p.authHander.Authenticator.ValidateConnStateForObo(ctx, r.TLS)
277+
if err != nil {
278+
if errors.Is(err, auth.ErrInvalidCertificate) {
279+
p.writeError(w, err, "failed to validate cetificate")
280+
return
281+
} else if errors.Is(err, cbauthx.ErrNoCert) {
282+
p.writeErrorWithStatus(w, err, "authorization header or client cert are required", 401)
283+
return
284+
}
285+
286+
p.writeErrorWithStatus(w, err, "received an unexpected cert authentication error", 401)
287+
return
288+
}
289+
290+
// We only set the on behalf of and auth headers if there is a user, if
291+
// we set the onbehalf of header to an empty string and use the admin
292+
// creds then the server seems to ignore the obo header.
293+
if oboUser != "" {
294+
oboHdrStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", oboUser, oboDomain)))
295+
proxyReq.Header.Set("cb-on-behalf-of", oboHdrStr)
296+
proxyReq.SetBasicAuth(p.username, p.password)
297+
}
298+
}
299+
255300
tr := otelhttp.NewTransport(
256301
roundTripper,
257302
// By setting the otelhttptrace client in this transport, it can be

gateway/gateway.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ func (g *Gateway) Run(ctx context.Context) error {
400400
Authenticator: authenticator,
401401
ProxyServices: proxyServices,
402402
ProxyBlockAdmin: config.ProxyBlockAdmin,
403+
Username: config.Username,
404+
Password: config.Password,
403405
})
404406

405407
config.Logger.Info("initializing protostellar system")

gateway/test/dapi_crud_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func (s *GatewayOpsTestSuite) RunCommonDapiErrorCases(
9595
})
9696
})
9797

98-
s.Run("Unauthenticated", func() {
98+
s.Run("P%", func() {
9999
resp := fn(&commonDapiTestData{
100100
BucketName: s.bucketName,
101101
ScopeName: s.scopeName,
@@ -106,7 +106,7 @@ func (s *GatewayOpsTestSuite) RunCommonDapiErrorCases(
106106
DocumentKey: s.randomDocId(),
107107
})
108108
require.NotNil(s.T(), resp)
109-
require.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
109+
require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
110110
// Authorization header missing is considered a missing parameter rather
111111
// than an authentication specific error.
112112
})

gateway/test/dapi_mtls_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package test
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
"net/http"
7+
"time"
8+
9+
"github.com/couchbase/stellar-gateway/testutils"
10+
"github.com/couchbase/stellar-gateway/utils/certificates"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func (s *GatewayOpsTestSuite) TestDapiClientCertAuth() {
16+
testutils.SkipIfNoDinoCluster(s.T())
17+
18+
s.Run("Tools", s.Tools)
19+
20+
s.Run("Proxy", s.Proxy)
21+
22+
s.Run("Crud", s.Crud)
23+
}
24+
25+
func (s *GatewayOpsTestSuite) Tools() {
26+
dino := testutils.StartDinoTesting(s.T(), false)
27+
username := "dapiUser"
28+
client := s.createMtlsClient(username)
29+
30+
s.Run("NonExistentUser", func() {
31+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
32+
Method: http.MethodGet,
33+
Path: "/v1/callerIdentity",
34+
}, client)
35+
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
36+
require.Contains(s.T(), string(resp.Body), "Your certificate is invalid")
37+
38+
})
39+
40+
dino.AddUnprivilegedUser(username)
41+
time.Sleep(time.Second * 5)
42+
s.T().Cleanup(func() {
43+
dino.RemoveUser(username)
44+
})
45+
46+
s.Run("Success", func() {
47+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
48+
Method: http.MethodGet,
49+
Path: "/v1/callerIdentity",
50+
}, client)
51+
requireRestSuccess(s.T(), resp)
52+
assert.JSONEq(s.T(), fmt.Sprintf(`{"user":"%s"}`, username), string(resp.Body))
53+
})
54+
}
55+
56+
func (s *GatewayOpsTestSuite) Proxy() {
57+
if !s.SupportsFeature(TestFeatureQuery) {
58+
s.T().Skip()
59+
}
60+
61+
dino := testutils.StartDinoTesting(s.T(), false)
62+
username := "proxyUser"
63+
client := s.createMtlsClient(username)
64+
65+
s.Run("NonExistentUser", func() {
66+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
67+
Method: http.MethodPost,
68+
Path: "/_p/query/query/service",
69+
Headers: map[string]string{
70+
"Content-Type": "application/json",
71+
},
72+
Body: []byte(`{"statement": "UPSERT INTO default (KEY, VALUE) VALUES ('query-insert', { 'hello': 'world' })"}`),
73+
}, client)
74+
require.Equal(s.T(), http.StatusBadGateway, resp.StatusCode)
75+
require.Contains(s.T(), string(resp.Body), "failed to validate cetificate")
76+
})
77+
78+
dino.AddUnprivilegedUser(username)
79+
time.Sleep(time.Second * 5)
80+
s.T().Cleanup(func() {
81+
dino.RemoveUser(username)
82+
})
83+
84+
s.Run("InsufficientPermissions", func() {
85+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
86+
Method: http.MethodPost,
87+
Path: "/_p/query/query/service",
88+
Headers: map[string]string{
89+
"Content-Type": "application/json",
90+
},
91+
Body: []byte(`{"statement": "UPSERT INTO default (KEY, VALUE) VALUES ('query-insert', { 'hello': 'world' })"}`),
92+
}, client)
93+
require.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
94+
require.Contains(s.T(), string(resp.Body), "User does not have credentials to run INSERT queries")
95+
})
96+
97+
dino.AddWriteUser(username)
98+
time.Sleep(time.Second * 5)
99+
100+
s.Run("Success", func() {
101+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
102+
Method: http.MethodPost,
103+
Path: "/_p/query/query/service",
104+
Headers: map[string]string{
105+
"Content-Type": "application/json",
106+
},
107+
Body: []byte(`{"statement": "UPSERT INTO default (KEY, VALUE) VALUES ('query-insert', { 'hello': 'world' })"}`),
108+
}, client)
109+
requireRestSuccess(s.T(), resp)
110+
})
111+
}
112+
113+
func (s *GatewayOpsTestSuite) Crud() {
114+
dino := testutils.StartDinoTesting(s.T(), false)
115+
username := "crudUser"
116+
client := s.createMtlsClient(username)
117+
118+
s.Run("NonExistentUser", func() {
119+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
120+
Method: http.MethodGet,
121+
Path: fmt.Sprintf(
122+
"/v1/buckets/%s/scopes/%s/collections/%s/documents/%s",
123+
s.bucketName, s.scopeName, s.collectionName, s.testDocId(),
124+
),
125+
}, client)
126+
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
127+
require.Contains(s.T(), string(resp.Body), "Your certificate is invalid")
128+
})
129+
130+
dino.AddUnprivilegedUser(username)
131+
time.Sleep(time.Second * 5)
132+
s.T().Cleanup(func() {
133+
dino.RemoveUser(username)
134+
})
135+
136+
s.Run("InsufficientReadPermissions", func() {
137+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
138+
Method: http.MethodGet,
139+
Path: fmt.Sprintf(
140+
"/v1/buckets/%s/scopes/%s/collections/%s/documents/%s",
141+
s.bucketName, s.scopeName, s.collectionName, s.testDocId(),
142+
),
143+
}, client)
144+
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
145+
require.Contains(s.T(), string(resp.Body), "access error")
146+
})
147+
148+
dino.AddReadOnlyUser(username)
149+
time.Sleep(time.Second * 5)
150+
151+
s.Run("ReadSuccess", func() {
152+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
153+
Method: http.MethodGet,
154+
Path: fmt.Sprintf(
155+
"/v1/buckets/%s/scopes/%s/collections/%s/documents/%s",
156+
s.bucketName, s.scopeName, s.collectionName, s.testDocId(),
157+
),
158+
}, client)
159+
requireRestSuccess(s.T(), resp)
160+
})
161+
162+
s.Run("InsufficientWritePermissions", func() {
163+
docId := s.randomDocId()
164+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
165+
Method: http.MethodPost,
166+
Path: fmt.Sprintf(
167+
"/v1/buckets/%s/scopes/%s/collections/%s/documents/%s",
168+
s.bucketName, s.scopeName, s.collectionName, docId,
169+
),
170+
Headers: map[string]string{
171+
"X-CB-Flags": fmt.Sprintf("%d", TEST_CONTENT_FLAGS),
172+
},
173+
Body: TEST_CONTENT,
174+
}, client)
175+
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
176+
require.Contains(s.T(), string(resp.Body), "access error")
177+
})
178+
179+
dino.AddWriteUser(username)
180+
time.Sleep(time.Second * 5)
181+
182+
s.Run("WriteSuccess", func() {
183+
docId := s.randomDocId()
184+
resp := s.sendTestHttpRequestWithClient(&testHttpRequest{
185+
Method: http.MethodPost,
186+
Path: fmt.Sprintf(
187+
"/v1/buckets/%s/scopes/%s/collections/%s/documents/%s",
188+
s.bucketName, s.scopeName, s.collectionName, docId,
189+
),
190+
Headers: map[string]string{
191+
"X-CB-Flags": fmt.Sprintf("%d", TEST_CONTENT_FLAGS),
192+
},
193+
Body: TEST_CONTENT,
194+
}, client)
195+
requireRestSuccess(s.T(), resp)
196+
})
197+
}
198+
199+
func (s *GatewayOpsTestSuite) createMtlsClient(username string) *http.Client {
200+
cert, err := certificates.GenerateSignedClientCert(s.caCert, s.caKey, username)
201+
assert.NoError(s.T(), err)
202+
203+
return &http.Client{
204+
Transport: &http.Transport{
205+
TLSClientConfig: &tls.Config{
206+
RootCAs: s.clientCaCertPool,
207+
Certificates: []tls.Certificate{*cert},
208+
},
209+
},
210+
}
211+
}

0 commit comments

Comments
 (0)