Skip to content

Commit 474f6cc

Browse files
committed
feat(grafana): add Grafana provider with org-scoped API resources
Add a new Grafana provider for querying organization resources via the Grafana HTTP API. Supports both Grafana Cloud and self-hosted instances using service account token authentication. Resources: - grafana.organization: org identity (id, name) - grafana.user: org users with roles and last-seen metadata - grafana.serviceAccount: service accounts with role and status - grafana.serviceAccountToken: SA tokens with expiration tracking - grafana.datasource: datasources with access mode and config - grafana.contactPoint: alerting contact points with settings - grafana.notificationPolicy: notification policy routing tree Connection features: - Bearer token auth via --token flag or GRAFANA_TOKEN env - Instance URL via --url flag or GRAFANA_URL env - Retry/backoff for transient failures (retryablehttp, 5 retries) - 30s request timeout - Platform identity: grafana-org with stable MRN Example usage: cnspec shell grafana --url http://localhost:3000 --token $TOKEN > grafana { * } grafana: { organization: grafana.organization id=1 name="Main Org." users: [ 0: grafana.user login="admin" role="Admin" ] serviceAccounts: [ 0: grafana.serviceAccount id=2 name="cnspec-test" role="Admin" ] datasources: [] contactPoints: [] notificationPolicy: grafana.notificationPolicy id = grafana-notification-policy }
1 parent 5cdca23 commit 474f6cc

File tree

19 files changed

+3586
-0
lines changed

19 files changed

+3586
-0
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ PROVIDERS := \
207207
gcp \
208208
github \
209209
gitlab \
210+
grafana \
210211
google-workspace \
211212
ipinfo \
212213
ipmi \
@@ -273,6 +274,7 @@ providers/test:
273274
@$(call testGoModProvider, providers/equinix)
274275
@$(call testGoModProvider, providers/gcp)
275276
@$(call testGoModProvider, providers/github)
277+
@$(call testGoModProvider, providers/grafana)
276278
@$(call testGoModProvider, providers/gitlab)
277279
@$(call testGoModProvider, providers/google-workspace)
278280
@$(call testGoModProvider, providers/ipinfo)

providers/grafana/config/config.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package config
5+
6+
import (
7+
"go.mondoo.com/mql/v13/providers-sdk/v1/inventory"
8+
"go.mondoo.com/mql/v13/providers-sdk/v1/plugin"
9+
"go.mondoo.com/mql/v13/providers/grafana/provider"
10+
)
11+
12+
var Config = plugin.Provider{
13+
Name: "grafana",
14+
ID: "go.mondoo.com/mql/v13/providers/grafana",
15+
Version: "13.0.0",
16+
ConnectionTypes: []string{provider.DefaultConnectionType},
17+
Connectors: []plugin.Connector{
18+
{
19+
Name: "grafana",
20+
Use: "grafana",
21+
Short: "a Grafana organization",
22+
Long: `Use the grafana provider to query resources in a Grafana organization.
23+
24+
Examples:
25+
cnspec shell grafana --url https://myorg.grafana.net --token <api-token>
26+
cnspec scan grafana --url https://myorg.grafana.net --token <api-token>
27+
28+
Notes:
29+
If you set the GRAFANA_TOKEN environment variable, you can omit the token flag.
30+
If you set the GRAFANA_URL environment variable, you can omit the url flag.
31+
`,
32+
MinArgs: 0,
33+
MaxArgs: 0,
34+
Discovery: []string{},
35+
Flags: []plugin.Flag{
36+
{
37+
Long: "token",
38+
Type: plugin.FlagType_String,
39+
Default: "",
40+
Desc: "Grafana service account token",
41+
},
42+
{
43+
Long: "url",
44+
Type: plugin.FlagType_String,
45+
Default: "",
46+
Desc: "Grafana instance URL (e.g., https://myorg.grafana.net)",
47+
},
48+
},
49+
},
50+
},
51+
AssetUrlTrees: []*inventory.AssetUrlBranch{
52+
{
53+
PathSegments: []string{"technology=saas", "provider=grafana"},
54+
Key: "kind",
55+
Title: "Kind",
56+
Values: map[string]*inventory.AssetUrlBranch{
57+
"org": nil,
58+
},
59+
},
60+
},
61+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) Mondoo, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package connection
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"net/http"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"github.com/hashicorp/go-retryablehttp"
16+
"github.com/rs/zerolog/log"
17+
"go.mondoo.com/mql/v13/logger/zerologadapter"
18+
"go.mondoo.com/mql/v13/providers-sdk/v1/inventory"
19+
"go.mondoo.com/mql/v13/providers-sdk/v1/plugin"
20+
"go.mondoo.com/mql/v13/providers-sdk/v1/vault"
21+
)
22+
23+
// GrafanaConnection holds the HTTP client and auth state for a Grafana instance.
24+
type GrafanaConnection struct {
25+
plugin.Connection
26+
Conf *inventory.Config
27+
asset *inventory.Asset
28+
client *http.Client
29+
baseURL string
30+
token string
31+
}
32+
33+
// NewGrafanaConnection constructs a GrafanaConnection, resolving credentials and
34+
// base URL from vault credentials, conf options, or environment variables.
35+
// Both token and baseURL are required; an error is returned if either is absent.
36+
func NewGrafanaConnection(id uint32, asset *inventory.Asset, conf *inventory.Config) (*GrafanaConnection, error) {
37+
conn := &GrafanaConnection{
38+
Connection: plugin.NewConnection(id, asset),
39+
Conf: conf,
40+
asset: asset,
41+
}
42+
43+
// Resolve token: vault credential (CredentialType_password) takes precedence over env.
44+
token := os.Getenv("GRAFANA_TOKEN")
45+
for _, cred := range conf.Credentials {
46+
if cred.Type == vault.CredentialType_password {
47+
token = string(cred.Secret)
48+
} else {
49+
log.Warn().Str("credential-type", cred.Type.String()).Msg("unsupported credential type for Grafana provider")
50+
}
51+
}
52+
token = strings.TrimSpace(token)
53+
if token == "" {
54+
return nil, errors.New("a valid Grafana token is required, pass --token '<yourtoken>' or set GRAFANA_TOKEN environment variable")
55+
}
56+
57+
// Resolve base URL: conf option takes precedence over env.
58+
baseURL := os.Getenv("GRAFANA_URL")
59+
if conf.Options != nil {
60+
if v, ok := conf.Options["url"]; ok && v != "" {
61+
baseURL = v
62+
}
63+
}
64+
baseURL = strings.TrimRight(baseURL, "/")
65+
if baseURL == "" {
66+
return nil, errors.New("a Grafana instance URL is required, pass --url '<url>' or set GRAFANA_URL environment variable")
67+
}
68+
69+
// Build a retryablehttp client that handles transient failures automatically.
70+
retryClient := retryablehttp.NewClient()
71+
retryClient.RetryMax = 5
72+
retryClient.Logger = zerologadapter.New(log.Logger)
73+
httpClient := retryClient.StandardClient()
74+
httpClient.Timeout = 30 * time.Second
75+
76+
conn.token = token
77+
conn.baseURL = baseURL
78+
conn.client = httpClient
79+
80+
return conn, nil
81+
}
82+
83+
// Name returns the connection type name.
84+
func (c *GrafanaConnection) Name() string {
85+
return "grafana"
86+
}
87+
88+
// Asset returns the inventory asset associated with this connection.
89+
func (c *GrafanaConnection) Asset() *inventory.Asset {
90+
return c.asset
91+
}
92+
93+
// BaseURL returns the trimmed base URL of the Grafana instance.
94+
func (c *GrafanaConnection) BaseURL() string {
95+
return c.baseURL
96+
}
97+
98+
// OrgID returns the org-id option value, or empty string if not set.
99+
func (c *GrafanaConnection) OrgID() string {
100+
if c.Conf.Options == nil {
101+
return ""
102+
}
103+
return c.Conf.Options["org-id"]
104+
}
105+
106+
// Get issues an authenticated GET request to baseURL+path and returns the raw
107+
// response. The caller is responsible for closing the response body and checking
108+
// the status code.
109+
func (c *GrafanaConnection) Get(ctx context.Context, path string) (*http.Response, error) {
110+
url := c.baseURL + path
111+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
112+
if err != nil {
113+
return nil, fmt.Errorf("grafana: failed to create request for %s: %w", path, err)
114+
}
115+
req.Header.Set("Authorization", "Bearer "+c.token)
116+
req.Header.Set("Accept", "application/json")
117+
req.Header.Set("Content-Type", "application/json")
118+
return c.client.Do(req)
119+
}
120+
121+
// grafanaOrgPlatform returns the canonical platform descriptor for a Grafana org.
122+
func grafanaOrgPlatform() *inventory.Platform {
123+
return &inventory.Platform{
124+
Name: "grafana-org",
125+
Title: "Grafana Organization",
126+
Family: []string{"grafana"},
127+
Kind: "api",
128+
Runtime: "grafana",
129+
TechnologyUrlSegments: []string{"saas", "grafana", "org"},
130+
}
131+
}
132+
133+
// PlatformInfo returns the platform descriptor for this connection.
134+
func (c *GrafanaConnection) PlatformInfo() (*inventory.Platform, error) {
135+
return grafanaOrgPlatform(), nil
136+
}
137+
138+
// Identifier returns the platform MRN for this Grafana org connection.
139+
// If org-id is set in options it is appended; otherwise the path ends at "org".
140+
func (c *GrafanaConnection) Identifier() string {
141+
base := "//platformid.api.mondoo.app/runtime/grafana/org"
142+
orgID := c.OrgID()
143+
if orgID != "" {
144+
return base + "/" + orgID
145+
}
146+
return base
147+
}

0 commit comments

Comments
 (0)