Skip to content

Commit d9aff8e

Browse files
authored
OAuth passthrough support (#145)
* Add passthrough client * Type updates * Use passthrough client where needed * Type updates and config editor refactor * Documentation updates * Review * Minor tests and fix defers
1 parent 51163c3 commit d9aff8e

6 files changed

Lines changed: 319 additions & 90 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Google Cloud Logging Data Source
22

33
## Overview
4+
45
The Google Cloud Logging Data Source is a backend data source plugin for Grafana,
56
which allows users to query and visualize their Google Cloud logs in Grafana.
67

@@ -35,11 +36,21 @@ If you host Grafana on a GCE VM, you can also use the [Compute Engine service ac
3536
Similar to [Prometheus data sources on Google Cloud](https://cloud.google.com/stackdriver/docs/managed-prometheus/query#use-serverless), you can also configure a scheduled job to use an OAuth2 access token to view the logs. Please follow the steps in the [data source syncer README](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/blob/main/datasource-syncer/README.md) to configure it.
3637

3738
### Service account impersonation
39+
3840
You can also configure the plugin to use [service account impersonation](https://cloud.google.com/iam/docs/service-account-impersonation).
3941
You need to ensure the service account used by this plugin has the `iam.serviceAccounts.getAccessToken` permission. This permission is in roles like the [Service Account Token Creator role](https://cloud.google.com/iam/docs/understanding-roles#iam.serviceAccountTokenCreator) (roles/iam.serviceAccountTokenCreator). Also, the service account impersonated
4042
by this plugin needs logging read and project list permissions.
4143

44+
### OAuth Passthrough
45+
46+
You can configure the data source to use the OAuth token of the signed in user to authenticate to Google Cloud Logging. This requires a Grafana instance that is configured with [Google authentication](https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/google/).
47+
48+
Once Grafana is configured with Google authentication for signing in, ensure that the scopes set in the Grafana configuration include: `https://www.googleapis.com/auth/userinfo.profile`, `https://www.googleapis.com/auth/userinfo.email`, and `https://www.googleapis.com/auth/logging.read`. The latter will allow the signed in user to read Google Cloud Logging data.
49+
50+
You can then configure the data source with the `OAuth Passthrough` authentication method. Ensure that you provide a default project ID otherwise the health-check will fail.
51+
4252
### Grafana Configuration
53+
4354
1. With Grafana restarted, navigate to `Configuration -> Data sources` (or the route `/datasources`)
4455
2. Click "Add data source"
4556
3. Select "Google Cloud Logging"
@@ -66,6 +77,7 @@ datasources:
6677
```
6778
6879
### Supported variables
80+
6981
The plugin currently supports variables for logging scopes. For example, you can define a project variable and switch between projects. The following screenshot shows an example using project, bucket, and view.
7082
7183
![template variables](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/blob/main/src/img/template_vars.png?raw=true)
@@ -74,6 +86,7 @@ Below is an example of defining a variable for log views.
7486
![define a variable](https://github.com/GoogleCloudPlatform/cloud-logging-data-source-plugin/blob/main/src/img/template_query_vars.png?raw=true)
7587
7688
### Alerting
89+
7790
[Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/fundamentals/data-source-alerting/) is not directly supported due to how [Logging Query Language](https://cloud.google.com/logging/docs/view/logging-query-language) works on Google Cloud. If you need to create alerts based on logs, consider using [Log-based metrics](https://cloud.google.com/logging/docs/logs-based-metrics) and a [Cloud Monitoring data source](https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/).
7891
7992
## Licenses

pkg/plugin/cloudlogging/client.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,37 @@ func NewClientWithAccessToken(ctx context.Context, accessToken string) (*Client,
185185
}, nil
186186
}
187187

188+
// NewClientWithPassThrough creates a new Clients using Oauth browser credentials
189+
func NewClientWithPassThrough(ctx context.Context, headers map[string]string) (*Client, error) {
190+
token, found := strings.CutPrefix(headers["Authorization"], "Bearer ")
191+
if !found || token == "" {
192+
return nil, errors.New("missing or invalid Authorization header")
193+
}
194+
195+
oauthOpt := option.WithTokenSource(
196+
oauth2.StaticTokenSource(&oauth2.Token{
197+
AccessToken: token,
198+
}),
199+
)
200+
client, err := logging.NewClient(ctx, oauthOpt, option.WithUserAgent("googlecloud-logging-datasource"))
201+
if err != nil {
202+
return nil, err
203+
}
204+
rClient, err := resourcemanager.NewService(ctx, oauthOpt, option.WithUserAgent("googlecloud-logging-datasource"))
205+
if err != nil {
206+
return nil, err
207+
}
208+
configClient, err := logging.NewConfigClient(ctx, oauthOpt, option.WithUserAgent("googlecloud-logging-datasource"))
209+
if err != nil {
210+
return nil, err
211+
}
212+
return &Client{
213+
lClient: client,
214+
rClient: rClient.Projects,
215+
configClient: configClient,
216+
}, nil
217+
}
218+
188219
// Close closes the underlying connection to the GCP API
189220
func (c *Client) Close() error {
190221
c.configClient.Close()
@@ -216,7 +247,7 @@ func (q *Query) String() string {
216247
func (c *Client) ListProjects(ctx context.Context) ([]string, error) {
217248
projectIDs := []string{}
218249
pageToken := ""
219-
250+
220251
for {
221252
call := c.rClient.List()
222253
if pageToken != "" {
@@ -239,7 +270,7 @@ func (c *Client) ListProjects(ctx context.Context) ([]string, error) {
239270
}
240271
pageToken = response.NextPageToken
241272
}
242-
273+
243274
return projectIDs, nil
244275
}
245276

pkg/plugin/plugin.go

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ var (
4242
)
4343

4444
const (
45-
privateKeyKey = "privateKey"
46-
gceAuthentication = "gce"
47-
jwtAuthentication = "jwt"
48-
accessTokenAuthentication = "accessToken"
49-
accessTokenKey = "accessToken"
45+
privateKeyKey = "privateKey"
46+
gceAuthentication = "gce"
47+
jwtAuthentication = "jwt"
48+
accessTokenAuthentication = "accessToken"
49+
accessTokenKey = "accessToken"
50+
oauthpassthroughAuthentication = "oauthPassthrough"
5051
)
5152

5253
// config is the fields parsed from the front end
@@ -57,6 +58,7 @@ type config struct {
5758
TokenURI string `json:"tokenUri"`
5859
ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"`
5960
UsingImpersonation bool `json:"usingImpersonation"`
61+
OAuthPassThru bool `json:"oauthPassThru"`
6062
}
6163

6264
// toServiceAccountJSON creates the serviceAccountJSON bytes from the config fields
@@ -96,6 +98,8 @@ func NewCloudLoggingDatasource(ctx context.Context, settings backend.DataSourceI
9698
conf.AuthType = accessTokenAuthentication
9799
}
98100

101+
oauthPassThrough := false
102+
99103
var client_err error
100104
var client *cloudlogging.Client
101105

@@ -128,6 +132,8 @@ func NewCloudLoggingDatasource(ctx context.Context, settings backend.DataSourceI
128132
return nil, errMissingAccessToken
129133
}
130134
client, client_err = cloudlogging.NewClientWithAccessToken(context.TODO(), accessToken)
135+
case oauthpassthroughAuthentication:
136+
oauthPassThrough = true
131137
default:
132138
return nil, fmt.Errorf("unknown authentication type: %s", conf.AuthType)
133139
}
@@ -137,22 +143,26 @@ func NewCloudLoggingDatasource(ctx context.Context, settings backend.DataSourceI
137143
}
138144

139145
return &CloudLoggingDatasource{
140-
client: client,
146+
client: client,
147+
oauthPassThrough: oauthPassThrough,
141148
}, nil
142149
}
143150

144151
// CloudLoggingDatasource is an example datasource which can respond to data queries, reports
145152
// its health and has streaming skills.
146153
type CloudLoggingDatasource struct {
147-
client cloudlogging.API
154+
client cloudlogging.API
155+
oauthPassThrough bool
148156
}
149157

150158
// Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance
151159
// created. As soon as datasource settings change detected by SDK old datasource instance will
152160
// be disposed and a new one will be created using NewSampleDatasource factory function.
153161
func (d *CloudLoggingDatasource) Dispose() {
154-
if err := d.client.Close(); err != nil {
155-
log.DefaultLogger.Error("failed closing client", "error", err)
162+
if d.client != nil {
163+
if err := d.client.Close(); err != nil {
164+
log.DefaultLogger.Error("failed closing client", "error", err)
165+
}
156166
}
157167
}
158168

@@ -161,6 +171,25 @@ func (d *CloudLoggingDatasource) Dispose() {
161171
func (d *CloudLoggingDatasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
162172
// log.DefaultLogger.Info("CallResource called")
163173

174+
client := d.client
175+
176+
if d.oauthPassThrough {
177+
headers := make(map[string]string)
178+
for k, v := range req.Headers {
179+
if strings.EqualFold(k, "Authorization") {
180+
headers["Authorization"] = v[0]
181+
break
182+
}
183+
}
184+
oauthClient, err := d.CreateOauthClient(ctx, headers)
185+
if err != nil {
186+
return err
187+
}
188+
189+
client = oauthClient
190+
defer client.Close()
191+
}
192+
164193
var body []byte
165194

166195
// Right now we only support calls to the following:
@@ -183,7 +212,7 @@ func (d *CloudLoggingDatasource) CallResource(ctx context.Context, req *backend.
183212
})
184213
}
185214
} else if resource == "projects" {
186-
projects, err := d.client.ListProjects(ctx)
215+
projects, err := client.ListProjects(ctx)
187216
if err != nil {
188217
log.DefaultLogger.Warn("problem listing projects", "error", err)
189218
}
@@ -199,7 +228,7 @@ func (d *CloudLoggingDatasource) CallResource(ctx context.Context, req *backend.
199228
reqUrl, _ := url.Parse(req.URL)
200229
params, _ := url.ParseQuery(reqUrl.RawQuery)
201230

202-
bucketNames, err := d.client.ListProjectBuckets(ctx, params.Get("ProjectId"))
231+
bucketNames, err := client.ListProjectBuckets(ctx, params.Get("ProjectId"))
203232
if err != nil {
204233
log.DefaultLogger.Warn("problem listing log buckets", "error", err)
205234
}
@@ -215,7 +244,7 @@ func (d *CloudLoggingDatasource) CallResource(ctx context.Context, req *backend.
215244
reqUrl, _ := url.Parse(req.URL)
216245
params, _ := url.ParseQuery(reqUrl.RawQuery)
217246

218-
views, err := d.client.ListProjectBucketViews(ctx, params.Get("ProjectId"), params.Get("BucketId"))
247+
views, err := client.ListProjectBucketViews(ctx, params.Get("ProjectId"), params.Get("BucketId"))
219248
if err != nil {
220249
log.DefaultLogger.Warn("problem listing log views", "error", err)
221250
}
@@ -246,13 +275,23 @@ func (d *CloudLoggingDatasource) CallResource(ctx context.Context, req *backend.
246275
// contains Frames ([]*Frame).
247276
func (d *CloudLoggingDatasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
248277
// log.DefaultLogger.Info("QueryData called")
278+
client := d.client
279+
280+
if d.oauthPassThrough {
281+
oauthClient, err := d.CreateOauthClient(ctx, req.Headers)
282+
if err != nil {
283+
return nil, err
284+
}
285+
client = oauthClient
286+
defer client.Close()
287+
}
249288

250289
// create response struct
251290
response := backend.NewQueryDataResponse()
252291

253292
// loop over queries and execute them individually.
254293
for _, q := range req.Queries {
255-
res := d.query(ctx, req.PluginContext, q)
294+
res := d.query(ctx, req.PluginContext, q, client)
256295

257296
// save the response in a hashmap
258297
// based on with RefID as identifier
@@ -271,7 +310,7 @@ type queryModel struct {
271310
ViewId string `json:"viewId"`
272311
}
273312

274-
func (d *CloudLoggingDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {
313+
func (d *CloudLoggingDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery, client cloudlogging.API) backend.DataResponse {
275314
response := backend.DataResponse{}
276315

277316
var q queryModel
@@ -301,7 +340,7 @@ func (d *CloudLoggingDatasource) query(ctx context.Context, pCtx backend.PluginC
301340
},
302341
}
303342

304-
logs, err := d.client.ListLogs(ctx, &clientRequest)
343+
logs, err := client.ListLogs(ctx, &clientRequest)
305344
if err != nil {
306345
response.Error = fmt.Errorf("query: %w", err)
307346
return response
@@ -344,6 +383,17 @@ func (d *CloudLoggingDatasource) query(ctx context.Context, pCtx backend.PluginC
344383
func (d *CloudLoggingDatasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
345384
// log.DefaultLogger.Info("CheckHealth called")
346385

386+
client := d.client
387+
388+
if d.oauthPassThrough {
389+
oauthClient, err := d.CreateOauthClient(ctx, req.Headers)
390+
if err != nil {
391+
return nil, err
392+
}
393+
client = oauthClient
394+
defer client.Close()
395+
}
396+
347397
var status = backend.HealthStatusOk
348398
settings := req.PluginContext.DataSourceInstanceSettings
349399

@@ -359,7 +409,13 @@ func (d *CloudLoggingDatasource) CheckHealth(ctx context.Context, req *backend.C
359409
}
360410
conf.DefaultProject = proj
361411
}
362-
if err := d.client.TestConnection(ctx, conf.DefaultProject); err != nil {
412+
if conf.DefaultProject == "" && conf.OAuthPassThru {
413+
return &backend.CheckHealthResult{
414+
Status: backend.HealthStatusError,
415+
Message: "Please define a default project for OAuth authentication",
416+
}, nil
417+
}
418+
if err := client.TestConnection(ctx, conf.DefaultProject); err != nil {
363419
return &backend.CheckHealthResult{
364420
Status: backend.HealthStatusError,
365421
Message: fmt.Sprintf("failed to run test query: %s", err),
@@ -371,3 +427,12 @@ func (d *CloudLoggingDatasource) CheckHealth(ctx context.Context, req *backend.C
371427
Message: fmt.Sprintf("Successfully queried logs from GCP project %s", conf.DefaultProject),
372428
}, nil
373429
}
430+
431+
func (d *CloudLoggingDatasource) CreateOauthClient(ctx context.Context, headers map[string]string) (*cloudlogging.Client, error) {
432+
client, err := cloudlogging.NewClientWithPassThrough(ctx, headers)
433+
if err != nil {
434+
return nil, err
435+
}
436+
437+
return client, nil
438+
}

pkg/plugin/plugin_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,62 @@ func TestQueryData_SingleLog(t *testing.T) {
193193
require.Equal(t, string(expectedFrame), string(serializedFrame))
194194
client.AssertExpectations(t)
195195
}
196+
197+
func TestNewCloudLoggingDatasource_OAuthPassthrough(t *testing.T) {
198+
jsonData := `{"oauthPassThru": true, "authenticationType": "oauthPassthrough", "defaultProject": "test-project"}`
199+
settings := backend.DataSourceInstanceSettings{
200+
JSONData: []byte(jsonData),
201+
}
202+
203+
instance, err := NewCloudLoggingDatasource(context.Background(), settings)
204+
require.NoError(t, err)
205+
require.NotNil(t, instance)
206+
207+
ds, ok := instance.(*CloudLoggingDatasource)
208+
require.True(t, ok)
209+
require.True(t, ds.oauthPassThrough)
210+
require.Nil(t, ds.client)
211+
}
212+
213+
func TestCreateOauthClient_Success(t *testing.T) {
214+
ds := &CloudLoggingDatasource{
215+
oauthPassThrough: true,
216+
}
217+
218+
headers := map[string]string{
219+
"Authorization": "Bearer test-token-123",
220+
}
221+
222+
client, err := ds.CreateOauthClient(context.Background(), headers)
223+
require.NoError(t, err)
224+
require.NotNil(t, client)
225+
defer client.Close()
226+
}
227+
228+
func TestCreateOauthClient_MissingAuthHeader(t *testing.T) {
229+
ds := &CloudLoggingDatasource{
230+
oauthPassThrough: true,
231+
}
232+
233+
headers := map[string]string{}
234+
235+
client, err := ds.CreateOauthClient(context.Background(), headers)
236+
require.Error(t, err)
237+
require.ErrorContains(t, err, "missing or invalid Authorization header")
238+
require.Nil(t, client)
239+
}
240+
241+
func TestCreateOauthClient_InvalidAuthHeader(t *testing.T) {
242+
ds := &CloudLoggingDatasource{
243+
oauthPassThrough: true,
244+
}
245+
246+
headers := map[string]string{
247+
"Authorization": "Basic invalid-auth",
248+
}
249+
250+
client, err := ds.CreateOauthClient(context.Background(), headers)
251+
require.Error(t, err)
252+
require.ErrorContains(t, err, "missing or invalid Authorization header")
253+
require.Nil(t, client)
254+
}

0 commit comments

Comments
 (0)