Generated banner for Admission Control
Generated logo for Admission Control

Admission Control

Grafana LabsOfficialPublished by Grafana Labs

  • Skill
  • Tool integration
  • Cloud and DevOps
  • Apache-2.0
  • No issues found

From the author

Use when the user asks to "write a validator", "add validation", "implement admission control", "write a mutating webhook", "add a mutation handler", "validate incoming resources", "implement admission logic", "add admission webhooks", "write ingress validation", or asks how to validate or mutate resources before they are persisted in a grafana-app-sdk app. Provides guidance on implementing validation and mutation admission handlers for grafana-app-sdk apps.
— the description by Grafana Labs

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides guidance on implementing admission control handlers for grafana-app-sdk applications. It covers the two primary types of admission: validation, which accepts or rejects requests, and mutation, which modifies resources before they are persisted. The guide explains how to implement the Validator and Mutator interfaces, register these handlers within the application, and follow best practices for error reporting and cross-field validation.

What it does

  • Defines the Validator interface for accepting or rejecting resource requests.
  • Defines the Mutator interface for modifying resources before persistence.
  • Provides patterns for immutability checks and cross-field validation.
  • Explains how to register admission handlers in the app configuration.
  • Details the differences between standalone operator and in-process plugin deployment modes.
  • Offers conventions for creating clear, user-actionable error messages.

When to use it

  • When you need to enforce business logic constraints on incoming resource requests.
  • When you need to set default values or normalize fields before saving a resource.
  • When you need to ensure certain fields remain immutable after resource creation.
  • When you need to validate that a referenced resource exists before allowing an operation.
  • When implementing custom admission logic for a grafana-app-sdk application.

Advantages

  • Consistent admission logic regardless of deployment mode.
  • Clear separation between validation and mutation concerns.
  • Standardized patterns for common Kubernetes-style validation tasks.
  • Integration with the native grafana-app-sdk lifecycle.

Usage examples

“How do I implement a validator to ensure a field is required in my grafana-app-sdk app?”
The agent provides the Validator interface implementation, showing how to check the field in the Validate method and return an error if it is empty.
“How can I set a default value for a field during resource creation?”
The agent provides a Mutator example demonstrating how to check for the AdmissionActionCreate action and update the object fields before returning the response.
“Where do I register my admission handlers in the app code?”
The agent shows the New function in pkg/app/app.go, demonstrating how to add Validator and Mutator instances to the ManagedKinds configuration.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/grafana/admission-control/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip admission-control.zip -d .agents/skills

Documentation by Grafana Labs

Admission Control

Admission control intercepts resource create/update requests before they are persisted. In grafana-app-sdk there are two types:

  • Validation — accept or reject a request; cannot modify the resource

  • Mutation — modify the resource before it is persisted (e.g. set defaults, normalize fields)

The app business logic for admission is identical whether the app runs as a standalone operator or inside grafana/apps. The only difference is the runtime: standalone apps stand up their own webhook server; grafana/apps apps have admission auto-registered as a Kubernetes plugin.

Getting Stubs

For standalone apps, if pkg/app/app.go does not yet exist, a stub App can be generated with:

grafana-app-sdk project component add operator

This creates scaffolded simple.App which admission handlers can be added to for each kind in ManagedKinds.

Validator Interface

// Implement this interface for each kind you want to validate
type Validator interface {
    Validate(ctx context.Context, request *app.AdmissionRequest) error
}
  • Return nil to admit the request

  • Return an error to reject it (the error message is returned to the API caller)

  • app.AdmissionRequest provides access to the incoming object and operation type

  • You can use k8s.NewAdmissionError(err error, statusCode int, reason string) (from "github.com/grafana/grafana-app-sdk/k8s") to better control the returned error information

Validator Example
type MyKindValidator struct{}

func (v *MyKindValidator) Validate(ctx context.Context, req *app.AdmissionRequest) error {
    obj, ok := req.Object.(*v1.MyKind)
    if !ok {
        return fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
    }

    // Validate spec fields
    if obj.Spec.Title == "" {
        return fmt.Errorf("spec.title is required")
    }

    if obj.Spec.Count < 0 {
        return fmt.Errorf("spec.count must be non-negative, got %d", obj.Spec.Count)
    }

    // Distinguish create vs update
    if req.Action == resource.AdmissionActionUpdate && req.OldObject != nil {
        old, ok := req.OldObject.(*v1.MyKind)
        if !ok {
            return fmt.Errorf("admission request old object was of invalid type %T (expected *v1.MyKind)", req.OldObject)
        }
        if old.Spec.Title != obj.Spec.Title {
            return fmt.Errorf("spec.title is immutable after creation")
        }
    }

    return nil
}

Mutating Admission (Mutator)

// Implement this interface to mutate resources before persistence
type Mutator interface {
    Mutate(ctx context.Context, request *app.AdmissionRequest) (*app.MutatingResponse, error)
}
  • Return a MutatingResponse containing the (optionally modified) object

  • Return an error to reject the request entirely

  • Best practice is to reject requests from validators, not mutators

Mutating Handler Example
type MyKindMutator struct{}

func (m *MyKindMutator) Mutate(
    ctx context.Context,
    req *app.AdmissionRequest,
) (*app.MutatingResponse, error) {
    obj, ok := req.Object.(*v1.MyKind)
    if !ok {
        return nil, fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
    }

    // Set defaults on create
    if req.Action == resource.AdmissionActionCreate {
        if obj.Spec.Description == "" {
            obj.Spec.Description = "No description provided"
        }
    }

    return &app.MutatingResponse{UpdatedObject: obj}, nil
}

Registering Admission Handlers

Register validators and mutators when building the app in pkg/app/app.go:

func New(cfg app.Config) (app.App, error) {
    cfg.KubeConfig.APIPath = "/apis"
    a, err := simple.NewApp(simple.AppConfig{
        ManagedKinds: []simple.AppManagedKind{
            {
                Kind:      v1.MyKindKind(),
                Validator: &MyKindValidator{},
                Mutator:   &MyKindMutator{},
            },
        },
    })
    if err != nil {
      return nil, fmt.Errorf("error creating app: %w", err)
    }
    if err = a.ValidateManifest(cfg.ManifestData); err != nil {
        return nil, fmt.Errorf("app manifest validation failed: %w", err)
    }
    return a, nil
}

Note that mutation and validation must also be enabled in the kind's CUE definition (mutation.operations and validation.operations fields) — see the cue-kind-definition skill for details.

Admission Request Fields

Key fields available on app.AdmissionRequest:

FieldTypeDescription
Objectresource.ObjectThe incoming resource (after decoding)
OldObjectresource.ObjectPrevious state (only on UPDATE operations)
Actionresource.AdmissionActionAdmissionActionCreate, AdmissionActionUpdate, AdmissionActionDelete, AdmissionActionConnect
UserInforesource.AdmissionUserInfoThe user making the request
KindstringThe Object kind
GroupstringThe Object API Group
VersionstringThe Object API Version

Validation Patterns

Common patterns to implement:

// Immutability check
if req.Action == resource.AdmissionActionUpdate && old.Spec.ImmutableField != obj.Spec.ImmutableField {
    return fmt.Errorf("spec.immutableField cannot be changed after creation")
}

// Cross-field validation
if obj.Spec.StartTime.After(obj.Spec.EndTime) {
    return fmt.Errorf("spec.startTime must be before spec.endTime")
}

// Referential validation (e.g. check referenced resource exists)
if _, err := v.client.Get(ctx, resource.Identifier{Name: obj.Spec.RefName, Namespace: obj.Namespace}); err != nil {
    return fmt.Errorf("referenced resource %q not found", obj.Spec.RefName)
}

Deployment Difference

ModeAdmission runtime
Standalone operatorApp starts a webhook server; Kubernetes routes admission requests to it
grafana/appsAdmission handlers are auto-registered as a Kubernetes in-process plugin — no separate server required

The handler code itself is identical in both cases.

Resources

Files

references/admission-patterns.md

Admission Control Patterns

Error Message Conventions

Return clear, user-actionable error messages. Follow Kubernetes API conventions:

// Field path in dot notation
return fmt.Errorf("spec.title: required field cannot be empty")
return fmt.Errorf("spec.count: must be >= 0, got %d", obj.Spec.Count)
return fmt.Errorf("spec.startTime: must be before spec.endTime")

// For multi-error validation, use a field.ErrorList (if using k8s apimachinery)
// import "k8s.io/apimachinery/pkg/util/validation/field"
var errs field.ErrorList
if obj.Spec.Title == "" {
    errs = append(errs, field.Required(field.NewPath("spec", "title"), ""))
}
if obj.Spec.Count < 0 {
    errs = append(errs, field.Invalid(
        field.NewPath("spec", "count"), obj.Spec.Count, "must be non-negative"))
}
if len(errs) > 0 {
    return errs.ToAggregate()
}

Prefer usage of k8s.NewAdmissionError when you want to be able to set both the Message and Reason in the returned meta.v1.Status error. Note that Message is intended for humans and Reason should be a machine-readable description.

Operation-Specific Validation

func (v *MyKindValidator) Validate(ctx context.Context, req *app.AdmissionRequest) error {
    obj, ok := req.Object.(*v1.MyKind)
    if !ok {
        return fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
    }
    switch req.Action {
    case resource.AdmissionActionCreate:
        return v.validateCreate(ctx, obj)
    case resource.AdmissionActionUpdate:
        if req.OldObject == nil {
            return errors.New("old object not present in AdmissionRequest")
        }
        old, ok := req.OldObject.(*v1.MyKind)
        if !ok {
            return fmt.Errorf("admission request old object was of invalid type %T (expected *v1.MyKind)", req.OldObject)
        }
        return v.validateUpdate(ctx, old, obj)
    case resource.AdmissionActionDelete:
        return v.validateDelete(ctx, obj)
    }
    return nil
}

func (v *MyKindValidator) validateCreate(ctx context.Context, obj *v1.MyKind) error {
    if obj.Spec.Title == "" {
        return fmt.Errorf("spec.title: required")
    }
    return nil
}

func (v *MyKindValidator) validateUpdate(ctx context.Context, old, updated *v1.MyKind) error {
    // Immutability checks
    if old.Spec.ImmutableField != updated.Spec.ImmutableField {
        return fmt.Errorf("spec.immutableField: field is immutable after creation")
    }
    // General field validation
    return v.validateCreate(ctx, updated)
}

func (v *MyKindValidator) validateDelete(ctx context.Context, obj *v1.MyKind) error {
    if obj.Spec.Permanent {
        return fmt.Errorf("cannot delete objects with spec.permanent set to true")
    }
    return nil
}

Immutability Pattern

For fields that should not change after creation:

func validateImmutableFields(old, new *v1.MyKind) error {
    if old.Spec.TenantID != new.Spec.TenantID {
        return fmt.Errorf("spec.tenantId: immutable after creation")
    }
    if old.Spec.Region != new.Spec.Region {
        return fmt.Errorf("spec.region: immutable after creation")
    }
    return nil
}

Cross-Field Validation

func validateCrossFields(obj *v1.MyKind) error {
    // Time range validation
    if !obj.Spec.StartTime.IsZero() && !obj.Spec.EndTime.IsZero() {
        if !obj.Spec.StartTime.Before(obj.Spec.EndTime) {
            return fmt.Errorf("spec.startTime must be before spec.endTime")
        }
    }

    // Dependency validation
    if obj.Spec.EnableFeatureX && obj.Spec.FeatureXConfig == nil {
        return fmt.Errorf("spec.featureXConfig: required when spec.enableFeatureX is true")
    }

    // Mutual exclusivity
    if obj.Spec.ModeA && obj.Spec.ModeB {
        return fmt.Errorf("spec.modeA and spec.modeB are mutually exclusive")
    }
    return nil
}

Referential Validation

Check that referenced resources actually exist:

import (
    "context"
    "fmt"
    "<module>/pkg/generated/<group>/v1"
    "github.com/grafana/grafana-app-sdk/app"
    "github.com/grafana/grafana-app-sdk/resource"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
)

type MyKindValidator struct {
    otherClient resource.Client
}

// Construct the validator in pkg/app/app.go, injecting a client:
//
//   registry := k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig())
//   otherClient, err := registry.ClientFor(otherv1.OtherKindKind())
//   validator := &MyKindValidator{otherClient: otherClient}
//
// Alternatively use a generated typed client from the target app if available.

func (v *MyKindValidator) Validate(ctx context.Context, req *app.AdmissionRequest) error {
    obj, ok := req.Object.(*v1.MyKind)
    if !ok {
        return fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
    }

    if obj.Spec.OtherRef != "" {
        _, err := v.otherClient.Get(ctx, resource.Identifier{
            Name:      obj.Spec.OtherRef,
            Namespace: obj.Namespace,
        })
        if err != nil {
            if apierrors.IsNotFound(err) {
                return fmt.Errorf("spec.otherRef: referenced resource %q not found", obj.Spec.OtherRef)
            }
            return fmt.Errorf("spec.otherRef: validating reference: %w", err)
        }
    }
    return nil
}

Mutation Patterns

Setting Defaults
func (m *MyKindMutator) Mutate(
    ctx context.Context,
    req *app.AdmissionRequest,
) (*app.MutatingResponse, error) {
    obj, ok := req.Object.(*v1.MyKind)
    if !ok {
        return nil, fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
    }

    if req.Action == resource.AdmissionActionCreate {
        // Set defaults not expressible in CUE
        if obj.Spec.Description == "" {
            obj.Spec.Description = fmt.Sprintf("Auto-created %s", obj.Name)
        }
        // Copy spec to label for list filtering
        if obj.Spec.UseFor != "" {
            labels := obj.Labels
            if labels == nil {
                labels = make(map[string]string)
            }
            labels["usefor"] = obj.Spec.UseFor
            obj.Labels = labels
        }
    }

    return &app.MutatingResponse{UpdatedObject: obj}, nil
}
Normalizing Input
// Normalize on both create and update
obj.Spec.Title = strings.TrimSpace(obj.Spec.Title)
obj.Spec.Tags = deduplicateAndSort(obj.Spec.Tags)

Testing Admission Handlers

Unit test validators directly without a running server:

func TestMyKindValidator_Validate(t *testing.T) {
    v := &MyKindValidator{}
    ctx := context.Background()

    tests := []struct {
        name    string
        obj     *v1.MyKind
        action  resource.AdmissionAction
        wantErr bool
        errMsg  string
    }{
        {
            name:   "valid create",
            obj:    &v1.MyKind{Spec: v1.MyKindSpec{Title: "Test"}},
            action: resource.AdmissionActionCreate,
        },
        {
            name:    "missing title",
            obj:     &v1.MyKind{Spec: v1.MyKindSpec{}},
            action:  resource.AdmissionActionCreate,
            wantErr: true,
            errMsg:  "spec.title",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := v.Validate(ctx, &app.AdmissionRequest{
                Object: tt.obj,
                Action: tt.action,
            })
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
            if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
                t.Errorf("error %q does not contain %q", err.Error(), tt.errMsg)
            }
        })
    }
}

Deployment: Standalone vs grafana/apps

The handler code is identical. The only difference is how admission is registered at runtime:

  • Standalone: App binary starts an HTTPS webhook server. Kubernetes ValidatingWebhookConfiguration and MutatingWebhookConfiguration resources route requests to it. The SDK handles this automatically when component add operator scaffolding is used.

  • grafana/apps: Admission handlers are registered in-process via the Grafana API server plugin mechanism. No separate webhook server is needed.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Grafana Labs
Publisher
Grafana Labs (Official)
Source
grafana/skills · skills/grafana-app-sdk/admission-control · commit 8fb72a5
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Grafana Labs or Grafana Labs.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download admission-control.zip