Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/v1beta1/grafana_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ type JsonnetConfig struct {

// GrafanaClient contains the Grafana API client settings
type GrafanaClient struct {
// Use Kubernetes Serviceaccount as authentication
// Requires configuring [auth.jwt] in the instance
// +optional
UseKubeAuth bool `json:"useKubeAuth,omitempty"`
// +nullable
TimeoutSeconds *int `json:"timeout,omitempty"`
// +nullable
Expand Down
5 changes: 5 additions & 0 deletions config/crd/bases/grafana.integreatly.org_grafanas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ spec:
x-kubernetes-validations:
- message: insecureSkipVerify and certSecretRef cannot be set at the same time
rule: (has(self.insecureSkipVerify) && !(has(self.certSecretRef))) || (has(self.certSecretRef) && !(has(self.insecureSkipVerify)))
useKubeAuth:
description: |-
Use Kubernetes Serviceaccount as authentication
Requires configuring [auth.jwt] in the instance
type: boolean
type: object
config:
additionalProperties:
Expand Down
78 changes: 77 additions & 1 deletion controllers/client/grafana_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"time"

"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
httptransport "github.com/go-openapi/runtime/client"
genapi "github.com/grafana/grafana-openapi-client-go/client"
"github.com/grafana/grafana-operator/v5/api/v1beta1"
Expand All @@ -17,12 +20,74 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
serviceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" // nolint:gosec
)

type grafanaAdminCredentials struct {
adminUser string
adminPassword string
apikey string
}

type JWTCache struct {
Token string
Expiration time.Time
}

var jwtCache *JWTCache

// Revoke tokens early expecting them to be rotated hourly, see 'ExpirationSeconds' in KEP1205
// Should mitigate mid-reconcile expiration
const tokenExpirationCompensation = -30 * time.Second

// getBearerToken will read JWT token from given file and cache it until it expires.
// accepts filepath arg for testing
func getBearerToken(bearerTokenPath string) (string, error) {
// Return cached token if not expired
if jwtCache != nil && jwtCache.Expiration.After(time.Now()) {
return jwtCache.Token, nil
}

b, err := os.ReadFile(bearerTokenPath)
if err != nil {
return "", fmt.Errorf("reading token file at %s, %w", bearerTokenPath, err)
}

token := string(b)

// List of accepted JWT signing algorithms from: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#:~:text=oidc-signing-algs
t, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{
jose.RS256, jose.RS384, jose.RS512,
jose.ES256, jose.ES384, jose.ES512,
jose.PS256, jose.PS384, jose.PS512,
})
if err != nil {
return "", err
}

claims := jwt.Claims{}

// TODO fetch JWKS from https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}/openid/v1/jwks
// Then verify token using the keys
err = t.UnsafeClaimsWithoutVerification(&claims)
Copy link
Collaborator

@weisdd weisdd Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just omit token validation? - Even though it's relatively easy to implement basic token validation (e.g. using NewProvider + Verifier from github.com/coreos/go-oidc/v3/oidc), it's up to Grafana to do the actual token validation / authorization (like with any other Grafana credentials that the operator uses), the role of the operator here is just to fetch the token from the file and pass it to Grafana when making API requests. - It would also simplify the unit test as you would only need to check that the Authorization header is set to the contents of the file. WDYT?

(We can discuss it further during the meeting)

Copy link
Collaborator Author

@Baarsgaard Baarsgaard Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I forgot that TODO comment!
We already discussed this in a weekly meeting and agreed to not validate the token aside from reading the expiration!
I will remove that comment/Change it to the reason why we do not validate the token.

And potentially simplify the test!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think it was @theSuess who suggested that, I just wanted to surface that conversation here as the code was still in place. We'll wait for further updates then :)

Copy link
Collaborator Author

@Baarsgaard Baarsgaard Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at it again, since the token lifetime is fully configurable, it's probably a good idea to read the expiration but not validate the token: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#launch-a-pod-using-service-account-token-projection
This allows us to dynamically set the cache timeout of the token if someone decides to shorten the lifetime.

The discussion we had last time was verifying the token signature with the JWKS downloaded from the kubernetes API, which we decided was not worth it?

if err != nil {
return "", fmt.Errorf("decoding ServiceAccount token %w", err)
}

tokenExpiration := claims.Expiry.Time()
if tokenExpiration.Add(tokenExpirationCompensation).Before(time.Now()) {
return "", fmt.Errorf("token expired at %s, expected %s to be renewed. Tokens are considered expired 30 seconds early", tokenExpiration.String(), bearerTokenPath)
}

jwtCache = &JWTCache{
Token: token,
Expiration: tokenExpiration.Add(tokenExpirationCompensation),
}

return token, nil
}

func getExternalAdminUser(ctx context.Context, c client.Client, cr *v1beta1.Grafana) (string, error) {
if cr.Spec.External != nil && cr.Spec.External.AdminUser != nil {
adminUser, err := GetValueFromSecretKey(ctx, cr.Spec.External.AdminUser, c, cr.Namespace)
Expand Down Expand Up @@ -63,6 +128,17 @@ func getExternalAdminPassword(ctx context.Context, c client.Client, cr *v1beta1.
func getAdminCredentials(ctx context.Context, c client.Client, grafana *v1beta1.Grafana) (*grafanaAdminCredentials, error) {
credentials := &grafanaAdminCredentials{}

if grafana.Spec.Client != nil && grafana.Spec.Client.UseKubeAuth {
t, err := getBearerToken(serviceAccountTokenPath)
if err != nil {
return nil, err
}

credentials.apikey = t

return credentials, nil
}

if grafana.IsExternal() {
// prefer api key if present
if grafana.Spec.External.APIKey != nil {
Expand Down Expand Up @@ -152,7 +228,7 @@ func InjectAuthHeaders(ctx context.Context, c client.Client, grafana *v1beta1.Gr
}

if creds.apikey != "" {
req.Header.Add("Authorization", "Bearer "+creds.apikey)
req.Header.Set("Authorization", "Bearer "+creds.apikey)
} else {
req.SetBasicAuth(creds.adminUser, creds.adminPassword)
}
Expand Down
91 changes: 91 additions & 0 deletions controllers/client/grafana_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ package client

import (
"context"
"crypto/rand"
"crypto/rsa"
"os"
"slices"
"testing"
"time"

"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/grafana/grafana-operator/v5/api/v1beta1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -312,3 +319,87 @@ func TestGetAdminCredentials(t *testing.T) {
assert.Nil(t, got)
})
}

func TestGetBearerToken(t *testing.T) {
t.Parallel()

now := time.Now()
body := jwt.Claims{
ID: "1234",
Issuer: "grafana.operator.com",
Audience: jwt.Audience{"https://grafana.operator.com"},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Duration(60 * float64(time.Second)))),
}

// Generate key
testRSAKey, err := rsa.GenerateKey(rand.Reader, 1024) // nolint:gosec
require.NotNil(t, testRSAKey)
require.NoError(t, err)

sk := jose.SigningKey{
Key: &jose.JSONWebKey{
Key: testRSAKey,
KeyID: "test-key",
},
Algorithm: jose.RS256,
}

// Create signer, and sign token
signer, err := jose.NewSigner(sk, &jose.SignerOptions{EmbedJWK: true})
require.NoError(t, err)

origToken, err := jwt.Signed(signer).Claims(body).Serialize()
require.NoError(t, err)

// Create tmp file
tokenFile, err := os.CreateTemp(os.TempDir(), "token-*")
require.NoError(t, err)
require.NotNil(t, testRSAKey)

// Do not read token file purposefully
jwtCache = nil
noToken, err := getBearerToken(tokenFile.Name() + "-extra")
require.Error(t, err)
require.Empty(t, noToken)
require.Nil(t, jwtCache)

// Write token to file
writtenBytes, err := tokenFile.WriteString(origToken)
require.Equal(t, len([]byte(origToken)), writtenBytes)
require.NoError(t, err)

// Decode token
token, err := getBearerToken(tokenFile.Name())
require.NoError(t, err)
require.Equal(t, origToken, token)
require.Equal(t, origToken, jwtCache.Token)
require.False(t, jwtCache.Expiration.IsZero())

// Expire the cache and re-read token
jwtCache.Expiration = time.Now().Add(-10 * time.Second)
token, err = getBearerToken(tokenFile.Name())
require.NoError(t, err)
require.Equal(t, origToken, token)
require.True(t, jwtCache.Expiration.After(time.Now()))

// Mangle token
reversedOrigToken := []byte(origToken)
slices.Reverse(reversedOrigToken)
reversedWrittenBytes, err := tokenFile.Write(reversedOrigToken)
require.NoError(t, err)
require.Equal(t, len([]byte(reversedOrigToken)), reversedWrittenBytes)

// Successfully get token from cache
token, err = getBearerToken(tokenFile.Name())
require.NoError(t, err)
require.Equal(t, origToken, token)

// Reset cache and error on mangled token
jwtCache = nil
mangledToken, err := getBearerToken(tokenFile.Name())
require.Error(t, err)
require.Empty(t, mangledToken)
require.Nil(t, jwtCache)
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ spec:
x-kubernetes-validations:
- message: insecureSkipVerify and certSecretRef cannot be set at the same time
rule: (has(self.insecureSkipVerify) && !(has(self.certSecretRef))) || (has(self.certSecretRef) && !(has(self.insecureSkipVerify)))
useKubeAuth:
description: |-
Use Kubernetes Serviceaccount as authentication
Requires configuring [auth.jwt] in the instance
type: boolean
type: object
config:
additionalProperties:
Expand Down
5 changes: 5 additions & 0 deletions deploy/kustomize/base/crds.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3407,6 +3407,11 @@ spec:
at the same time
rule: (has(self.insecureSkipVerify) && !(has(self.certSecretRef)))
|| (has(self.certSecretRef) && !(has(self.insecureSkipVerify)))
useKubeAuth:
description: |-
Use Kubernetes Serviceaccount as authentication
Requires configuring [auth.jwt] in the instance
type: boolean
type: object
config:
additionalProperties:
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6444,6 +6444,14 @@ Client defines how the grafana-operator talks to the grafana instance.
<i>Validations</i>:<li>(has(self.insecureSkipVerify) && !(has(self.certSecretRef))) || (has(self.certSecretRef) && !(has(self.insecureSkipVerify))): insecureSkipVerify and certSecretRef cannot be set at the same time</li>
</td>
<td>false</td>
</tr><tr>
<td><b>useKubeAuth</b></td>
<td>boolean</td>
<td>
Use Kubernetes Serviceaccount as authentication
Requires configuring [auth.jwt] in the instance<br/>
</td>
<td>false</td>
</tr></tbody>
</table>

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down