Skip to content

feat: add Grafana auth util for operators outside Grafana repo#1327

Open
konsalex wants to merge 3 commits intomainfrom
grafana-auth-util-provider
Open

feat: add Grafana auth util for operators outside Grafana repo#1327
konsalex wants to merge 3 commits intomainfrom
grafana-auth-util-provider

Conversation

@konsalex
Copy link
Copy Markdown
Contributor

@konsalex konsalex commented Apr 8, 2026

What Changed? Why?

Closed https://github.com/grafana/grafana-app-platform-squad/issues/94#issue-4194627237

How was it tested?

Vendored inside Grafana Enterprise (widget operator).

The operator then watched Folders (service-to-service) and Widgets (apiextensions) succesfully.

{"time":"2026-04-08T14:31:09.328934763Z","level":"INFO","msg":"Widget added","name":"my-widget","namespace":"default"}
{"time":"2026-04-08T14:31:09.332925684Z","level":"INFO","msg":"Folder created (via folder-apiserver token exchange)","name":"my-folder-2222","namespace":"stacks-11"}
{"time":"2026-04-08T14:31:09.333016559Z","level":"INFO","msg":"Folder created (via folder-apiserver token exchange)","name":"my-folder","namespace":"stacks-11"}
{"time":"2026-04-08T14:32:26.372064907Z","level":"INFO","msg":"Widget added","name":"my-widget-2","namespace":"default"}

The whole widget becomes like this:

File: widget.go
package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"time"

	"github.com/grafana/grafana-app-sdk/app"
	"github.com/grafana/grafana-app-sdk/k8s"
	"github.com/grafana/grafana-app-sdk/logging"
	"github.com/grafana/grafana-app-sdk/operator"
	"github.com/grafana/grafana-app-sdk/resource"
	"github.com/grafana/grafana-app-sdk/simple"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/rest"
)

func main() {
	// Configure the default logger to use slog
	logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	}))

	// Load config from env
	webhookPort := 8443
	tlsCertPath := ""
	tlsKeyPath := ""
	if whPortStr := os.Getenv("WEBHOOK_PORT"); whPortStr != "" {
		var err error
		webhookPort, err = strconv.Atoi(whPortStr)
		if err != nil {
			panic(fmt.Errorf("invalid WEBHOOK_PORT: %w", err))
		}
	}
	tlsCertPath = os.Getenv("WEBHOOK_CERT_PATH")
	tlsKeyPath = os.Getenv("WEBHOOK_KEY_PATH")

	restCfg, err := rest.InClusterConfig()
	if err != nil {
		panic(err)
	}

	kubeCfg := *restCfg

	// --- Token exchange authentication ---
	//
	// The apiextensions-apiserver uses Grafana's JWT-based auth (via authlib), not
	// Kubernetes RBAC. A service account token is meaningless to it and results in
	// "system:anonymous". To authenticate, we use the SDK's token exchange utility
	// which handles JWT exchange and injection via X-Access-Token header.
	//
	// Required env vars (set in widget-operator.yaml):
	//   TOKEN_EXCHANGE_URL   - auth-signer endpoint (e.g. http://auth-signer.default.svc.cluster.local:6481/sign/access-token)
	//   TOKEN_EXCHANGE_TOKEN - static CAP token the signer recognises (e.g. the access-policy:mycap token)
	//   APIEXTENSIONS_HOST   - direct URL of the apiextensions-apiserver (e.g. https://apiextensions-apiserver.default.svc.cluster.local:6453)
	//
	// Optional env vars:
	//   FOLDER_APP_URL       - direct URL of the folder-apiserver (e.g. https://folder-apiserver.default.svc.cluster.local:6446)
	//                          When set, the operator also watches folder resources to validate token exchange
	//                          against a second service.
	var tokenExchangeCreds *k8s.TokenExchangeCredentials
	if tokenExchangeURL := os.Getenv("TOKEN_EXCHANGE_URL"); tokenExchangeURL != "" {
		tokenExchangeToken := os.Getenv("TOKEN_EXCHANGE_TOKEN")
		apiextensionsHost := os.Getenv("APIEXTENSIONS_HOST")

		tokenExchangeCreds = &k8s.TokenExchangeCredentials{
			TokenExchangeURL: tokenExchangeURL,
			Token:            tokenExchangeToken,
		}

		cfg, err := k8s.NewTokenExchangeRestConfig(*tokenExchangeCreds, k8s.RemoteServiceTarget{
			Host:        apiextensionsHost,
			Audiences:   []string{"apiextensions.k8s.io"},
			InsecureTLS: true, // matches insecureSkipTLSVerify in the APIService definition
		})
		if err != nil {
			panic(fmt.Errorf("failed to create token exchange rest config: %w", err))
		}
		kubeCfg = *cfg
		logging.DefaultLogger.Info("Using token exchange auth for apiextensions-apiserver",
			"host", apiextensionsHost, "tokenExchangeURL", tokenExchangeURL)
	}

	operatorConfig := operator.RunnerConfig{
		KubeConfig: kubeCfg,
		WebhookConfig: operator.RunnerWebhookConfig{
			Port: webhookPort,
			TLSConfig: k8s.TLSConfig{
				CertPath: tlsCertPath,
				KeyPath:  tlsKeyPath,
			},
		},
		MetricsConfig: operator.RunnerMetricsConfig{
			Enabled: true,
		},
	}
	runner, err := operator.NewRunner(operatorConfig)
	if err != nil {
		logging.DefaultLogger.With("error", err).Error("Unable to create operator runner")
		panic(err)
	}

	// Context and cancel for the operator's Run method
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
	defer cancel()

	// Run
	folderAppURL := os.Getenv("FOLDER_APP_URL")
	logging.DefaultLogger.Info("Starting customcrdtest operator")
	err = runner.Run(ctx, NewProvider(tokenExchangeCreds, folderAppURL))
	if err != nil {
		logging.DefaultLogger.With("error", err).Error("Operator exited with error")
		panic(err)
	}
	logging.DefaultLogger.Info("Normal operator exit")
}

func NewProvider(tokenExchangeCreds *k8s.TokenExchangeCredentials, folderAppURL string) app.Provider {
	return simple.NewAppProvider(app.NewEmbeddedManifest(appManifest), nil, func(cfg app.Config) (app.App, error) {
		return NewApp(cfg, tokenExchangeCreds, folderAppURL)
	})
}

func NewApp(cfg app.Config, tokenExchangeCreds *k8s.TokenExchangeCredentials, folderAppURL string) (app.App, error) {
	cfg.KubeConfig.APIPath = "/apis"

	config := simple.AppConfig{
		Name:       "customcrdtest",
		KubeConfig: cfg.KubeConfig,
		ManagedKinds: []simple.AppManagedKind{
			{
				Kind: widgetv0alpha1Kind,
				Validator: &simple.Validator{
					ValidateFunc: func(ctx context.Context, ar *app.AdmissionRequest) error {
						if ar.Object.GetName() == "notallowed" {
							return errors.New("name 'notallowed' is not allowed")
						}
						return nil
					},
				},
				Mutator: &simple.Mutator{
					MutateFunc: func(ctx context.Context, ar *app.AdmissionRequest) (*app.MutatingResponse, error) {
						annotations := ar.Object.GetAnnotations()
						if annotations == nil {
							annotations = make(map[string]string)
						}
						annotations["widget-operator/modified"] = time.Now().Format(time.RFC3339)
						ar.Object.SetAnnotations(annotations)
						return &app.MutatingResponse{
							UpdatedObject: ar.Object,
						}, nil
					},
				},
			},
			{
				Kind:    widgetv1Kind,
				Watcher: &widgetWatcher{},
				ReconcileOptions: simple.BasicReconcileOptions{
					Namespace: resource.NamespaceAll,
				},
				Validator: &simple.Validator{
					ValidateFunc: func(ctx context.Context, ar *app.AdmissionRequest) error {
						if ar.Object.GetName() == "notallowed" {
							return errors.New("name 'notallowed' is not allowed")
						}
						return nil
					},
				},
				Mutator: &simple.Mutator{
					MutateFunc: func(ctx context.Context, ar *app.AdmissionRequest) (*app.MutatingResponse, error) {
						annotations := ar.Object.GetAnnotations()
						if annotations == nil {
							annotations = make(map[string]string)
						}
						annotations["widget-operator/modified"] = time.Now().Format(time.RFC3339)
						ar.Object.SetAnnotations(annotations)
						return &app.MutatingResponse{
							UpdatedObject: ar.Object,
						}, nil
					},
				},
			},
		},
		Converters: map[schema.GroupKind]simple.Converter{
			{Group: apiGroup, Kind: kind}: &converter{},
		},
	}

	// When FOLDER_APP_URL is set, watch folder resources via a separate token-exchanged
	// connection to the folder-apiserver. This validates that token exchange works for
	// both the apiextensions-apiserver (widgets) and a direct service (folders).
	if folderAppURL != "" && tokenExchangeCreds != nil {
		folderRemoteCfg, err := k8s.NewTokenExchangeRemoteRestConfig(*tokenExchangeCreds, k8s.RemoteServiceTarget{
			Host:        folderAppURL,
			Audiences:   []string{folderGroup},
			InsecureTLS: true,
		})
		if err != nil {
			return nil, fmt.Errorf("failed to create folder token exchange config: %w", err)
		}

		config.ClientGenerator = k8s.NewClientRegistry(cfg.KubeConfig, k8s.NewClientConfigWithExternalClients(
			map[string]*k8s.RemoteRestConfig{
				folderGroup: folderRemoteCfg,
			},
		))

		config.UnmanagedKinds = []simple.AppUnmanagedKind{
			{
				Kind: folderv1Kind,
				Watcher: &simple.Watcher{
					AddFunc: func(ctx context.Context, obj resource.Object) error {
						logging.FromContext(ctx).Info("Folder created (via folder-apiserver token exchange)",
							"name", obj.GetName(), "namespace", obj.GetNamespace())
						return nil
					},
					UpdateFunc: func(ctx context.Context, old, new resource.Object) error {
						logging.FromContext(ctx).Info("Folder updated (via folder-apiserver token exchange)",
							"name", new.GetName(), "namespace", new.GetNamespace())
						return nil
					},
					DeleteFunc: func(ctx context.Context, obj resource.Object) error {
						logging.FromContext(ctx).Info("Folder deleted (via folder-apiserver token exchange)",
							"name", obj.GetName(), "namespace", obj.GetNamespace())
						return nil
					},
				},
				ReconcileOptions: simple.UnmanagedKindReconcileOptions{
					Namespace: resource.NamespaceAll,
				},
			},
		}

		logging.DefaultLogger.Info("Folder watching enabled via token exchange",
			"folderAppURL", folderAppURL, "audience", folderGroup)
	}

	// Create the App
	a, err := simple.NewApp(config)
	if err != nil {
		return nil, err
	}

	// Validate the capabilities against the provided manifest to make sure there isn't a mismatch
	err = a.ValidateManifest(cfg.ManifestData)
	return a, err
}

type converter struct{}

func (*converter) Convert(src k8s.RawKind, dst string) ([]byte, error) {
	logging.DefaultLogger.Info("Converting object", "src", src.APIVersion, "dst", dst)
	switch src.Version {
	case widgetv0alpha1Kind.Version():
		srcObj := resource.TypedSpecObject[widgetv0alpha1spec]{}
		err := widgetv0alpha1Kind.Codecs[resource.KindEncodingJSON].Read(bytes.NewReader(src.Raw), &srcObj)
		if err != nil {
			return nil, k8s.NewAdmissionError(fmt.Errorf("cannot unmarshal v0alpha1 widget: %w", err), http.StatusInternalServerError, "ERR_UNMARSHAL")
		}
		switch dst {
		case fmt.Sprintf("%s/%s", widgetv1Kind.Group(), widgetv1Kind.Version()):
			dstObj := resource.TypedSpecObject[widgetv1spec]{}
			dstObj.Kind = kind
			dstObj.APIVersion = dst
			srcObj.ObjectMeta.DeepCopyInto(&dstObj.ObjectMeta)
			dstObj.Spec.Size = srcObj.Spec.Size
			buf := bytes.Buffer{}
			err := widgetv1Kind.Write(&dstObj, &buf, resource.KindEncodingJSON)
			return buf.Bytes(), err
		default:
			return nil, k8s.NewAdmissionError(fmt.Errorf("cannot convert v0alpha1 to unknown APIVersion %s", dst), http.StatusBadRequest, "ERR_INVALID_VERSION")
		}
	case widgetv1Kind.Version():
		srcObj := resource.TypedSpecObject[widgetv1spec]{}
		err := widgetv1Kind.Codecs[resource.KindEncodingJSON].Read(bytes.NewReader(src.Raw), &srcObj)
		if err != nil {
			return nil, k8s.NewAdmissionError(fmt.Errorf("cannot unmarshal v1 widget: %w", err), http.StatusInternalServerError, "ERR_UNMARSHAL")
		}
		switch dst {
		case fmt.Sprintf("%s/%s", widgetv0alpha1Kind.Group(), widgetv0alpha1Kind.Version()):
			dstObj := resource.TypedSpecObject[widgetv0alpha1spec]{}
			dstObj.Kind = kind
			dstObj.APIVersion = dst
			srcObj.ObjectMeta.DeepCopyInto(&dstObj.ObjectMeta)
			dstObj.Spec.Size = srcObj.Spec.Size
			buf := bytes.Buffer{}
			err := widgetv0alpha1Kind.Write(&dstObj, &buf, resource.KindEncodingJSON)
			return buf.Bytes(), err
		default:
			return nil, k8s.NewAdmissionError(fmt.Errorf("cannot convert v1 to unknown APIVersion %s", dst), http.StatusBadRequest, "ERR_INVALID_VERSION")
		}
	default:
		return nil, k8s.NewAdmissionError(fmt.Errorf("unknown source version %s", src.Version), http.StatusBadRequest, "ERR_INVALID_VERSION")
	}
}

type widgetWatcher struct{}

func (w *widgetWatcher) Add(ctx context.Context, obj resource.Object) error {
	logging.FromContext(ctx).Info("Widget added", "name", obj.GetName(), "namespace", obj.GetNamespace())
	return nil
}

func (w *widgetWatcher) Update(ctx context.Context, old, new resource.Object) error {
	logging.FromContext(ctx).Info("Widget updated", "name", new.GetName(), "namespace", new.GetNamespace())
	return nil
}

func (w *widgetWatcher) Delete(ctx context.Context, obj resource.Object) error {
	logging.FromContext(ctx).Info("Widget deleted", "name", obj.GetName(), "namespace", obj.GetNamespace())
	return nil
}

type widgetv0alpha1spec struct {
	Size string `json:"size"`
}

type widgetv1spec struct {
	Size     string `json:"size"`
	Replicas int    `json:"replicas"`
}

// folderv1spec is a minimal representation of the folder spec, sufficient for watching.
type folderv1spec struct {
	Title       string `json:"title"`
	Description string `json:"description,omitempty"`
}

var (
	apiGroup     = "customcrdtest.ext.grafana.app"
	kind         = "Widget"
	plural       = "widgets"
	folderGroup  = "folder.grafana.app"
	folderv1Kind = resource.Kind{
		Schema: resource.NewSimpleSchema(folderGroup, "v1", &resource.TypedSpecObject[folderv1spec]{}, &resource.UntypedList{},
			resource.WithKind("Folder"), resource.WithPlural("folders"), resource.WithScope(resource.NamespacedScope)),
		Codecs: map[resource.KindEncoding]resource.Codec{
			resource.KindEncodingJSON: resource.NewJSONCodec(),
		},
	}
	widgetv0alpha1Kind = resource.Kind{
		Schema: resource.NewSimpleSchema(apiGroup, "v0alpha1", &resource.TypedSpecObject[widgetv0alpha1spec]{}, &resource.UntypedList{}, resource.WithKind(kind),
			resource.WithPlural(plural), resource.WithScope(resource.NamespacedScope)),
		Codecs: map[resource.KindEncoding]resource.Codec{
			resource.KindEncodingJSON: resource.NewJSONCodec(),
		},
	}
	widgetv1Kind = resource.Kind{
		Schema: resource.NewSimpleSchema(apiGroup, "v1", &resource.TypedSpecObject[widgetv1spec]{}, &resource.UntypedList{}, resource.WithKind(kind),
			resource.WithPlural(plural), resource.WithScope(resource.NamespacedScope)),
		Codecs: map[resource.KindEncoding]resource.Codec{
			resource.KindEncodingJSON: resource.NewJSONCodec(),
		},
	}
	appManifest = app.ManifestData{
		AppName:          "customcrdtest",
		Group:            apiGroup,
		PreferredVersion: "v1",
		Versions: []app.ManifestVersion{{
			Name:   "v0alpha1",
			Served: true,
			Kinds: []app.ManifestVersionKind{{
				Kind:   widgetv0alpha1Kind.Kind(),
				Plural: widgetv0alpha1Kind.Plural(),
				Scope:  "Namespaced",
				Admission: &app.AdmissionCapabilities{
					Validation: &app.ValidationCapability{
						Operations: []app.AdmissionOperation{app.AdmissionOperationCreate, app.AdmissionOperationUpdate},
					},
					Mutation: &app.MutationCapability{
						Operations: []app.AdmissionOperation{app.AdmissionOperationCreate, app.AdmissionOperationUpdate},
					},
				},
				Conversion: true,
			}},
		}, {
			Name:   "v1",
			Served: true,
			Kinds: []app.ManifestVersionKind{{
				Kind:   widgetv1Kind.Kind(),
				Plural: widgetv1Kind.Plural(),
				Scope:  "Namespaced",
				Admission: &app.AdmissionCapabilities{
					Validation: &app.ValidationCapability{
						Operations: []app.AdmissionOperation{app.AdmissionOperationCreate, app.AdmissionOperationUpdate},
					},
					Mutation: &app.MutationCapability{
						Operations: []app.AdmissionOperation{app.AdmissionOperationCreate, app.AdmissionOperationUpdate},
					},
				},
				Conversion: true,
			}},
		}},
	}
)

Where did you document your changes?

Notes to Reviewers

@konsalex konsalex requested a review from a team as a code owner April 8, 2026 15:01
@konsalex konsalex requested review from radiohead and spinillos April 8, 2026 15:01
@joeblubaugh
Copy link
Copy Markdown
Contributor

Can this include documentation changes as well? I don't want those to fall by the wayside as the number of options increase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants