Race condition / Authorization bypass in provisioning job mutual exclusion

HIGH
grafana/grafana
Commit: 253bdbd309e7
Affected: 12.4.0 and earlier in the Grafana provisioning jobs feature (v0alpha1)
2026-07-02 17:04 UTC

Description

The commit implements a fix for a race/authorization bypass in provisioning job mutual exclusion. Prior to this change, ownership of a provisioning job was inferred from a claim timestamp label alone, with no worker identity. Because job names are deterministic (repository + action), a worker could lose ownership (e.g., the job is reaped and re-created under the same name) but still renew or complete the job, potentially causing two workers to execute the same job or allow a worker to delete a job it no longer owns. The patch introduces a per-claim owner token (provisioning.grafana.app/claim-owner) and verifies both the owner token and the object UID before renewing or completing a lease, making a lost lease effectively a hard failure (ErrLeaseLost). It also adjusts claim/rollback behavior and adds tests. Overall, this is a real vulnerability fix addressing a race-condition/authorization bypass risk around mutual exclusion for provisioning jobs.

Proof of Concept

Proof-of-concept (Go-like pseudo-implementation to illustrate the race pre-fix): // This is a simplified in-memory demonstration of the race that the patch fixes. // It is not a drop-in replacement for the Grafana code but shows the exploit path. package main import ( "fmt" "time" ) const LabelJobClaim = "provisioning.grafana.app/claim" const LabelJobClaimOwner = "provisioning.grafana.app/claim-owner" type Job struct { Namespace string Name string UID string Labels map[string]string } type Store struct { jobs map[string]*Job } func key(ns, name string) string { return ns + "/" + name } func NewStore() *Store { return &Store{jobs: make(map[string]*Job)} } func (s *Store) CreateJob(ns, name, uid string) { j := &Job{Namespace: ns, Name: name, UID: uid, Labels: map[string]string{}} s.jobs[key(ns, name)] = j } // Claim stamps a claim timestamp and an owner token on the existing job. func (s *Store) Claim(ns, name string) string { j := s.jobs[key(ns, name)] if j == nil { panic("job not found on Claim") } ts := time.Now().UnixNano() if j.Labels == nil { j.Labels = make(map[string]string) } j.Labels[LabelJobClaim] = fmt.Sprintf("%d", ts) owner := fmt.Sprintf("owner-%d", ts) // deterministic within the claim j.Labels[LabelJobClaimOwner] = owner return owner } // Reclaim simulates another worker reaping the same named job and replacing it with a new UID/owner. func (s *Store) Reclaim(ns, name, newUID, newOwner string) { s.jobs[key(ns, name)] = &Job{ Namespace: ns, Name: name, UID: newUID, Labels: map[string]string{ LabelJobClaim: fmt.Sprintf("%d", time.Now().UnixNano()), LabelJobClaimOwner: newOwner, }, } } // RenewLease represents what happens when the old worker tries to renew its lease. // In the vulnerable pre-fix scenario, the code would renew as long as a claim exists, // ignoring UID/owner mismatches. func (s *Store) RenewLease(ns, name, uid, owner string) error { j := s.jobs[key(ns, name)] if j == nil { return fmt.Errorf("not found") } // Vulnerable behavior (pre-fix): only checks that a claim exists; ignores ownership/UID if j.Labels != nil && j.Labels[LabelJobClaim] != "" { // update claim timestamp to indicate renewal j.Labels[LabelJobClaim] = fmt.Sprintf("%d", time.Now().UnixNano()) return nil } return fmt.Errorf("lease not found") } func main() { s := NewStore() // Initial setup: a job in namespace ns with deterministic name s.CreateJob("ns", "deterministic-job", "uid-A") ownerA := s.Claim("ns", "deterministic-job") _ = ownerA // not used further in this toy PoC // Simulate a second worker reaping the same named job and creating a new incarnation // with a new UID and owner s.Reclaim("ns", "deterministic-job", "uid-B", "owner-B") // Worker A attempts to RenewLease using its old UID/owner // In the pre-fix vulnerable behavior, this would succeed (thereby violating mutual exclusion) // In the real patch, the code would detect UID/owner mismatch and return ErrLeaseLost. err := s.RenewLease("ns", "deterministic-job", "uid-A", ownerA) if err != nil { fmt.Println("RenewLease failed (as would be expected in patched code):", err) } else { fmt.Println("RenewLease succeeded (vulnerability demonstrated in pre-fix behavior)") } }

Commit Details

Author: Roberto Jiménez Sánchez

Date: 2026-07-02 16:02 UTC

Message:

Provisioning: verify claim ownership so two pods don't run the same job (#127783) * Provisioning: verify claim ownership so two pods don't run the same job Job mutual exclusion was enforced only at initial claim (resourceVersion CAS). Ongoing ownership relied on a claim label holding only a timestamp, with no worker identity. Because job names are deterministic (repository + action), a reaped job and its re-created namesake share a name, so a worker could not tell its own claim apart from another worker's claim on the same name. As a result RenewLease and Complete would operate on a job the worker no longer owned, letting two pods run the same job. Add an owner token (provisioning.grafana.app/claim-owner) stamped at claim time, and verify both the owner token and the object UID before renewing or completing: - Claim stamps a unique owner token; the claim rollback clears it. - RenewLease refuses to renew unless the owner token and UID both match the claim we placed, returning ErrLeaseLost otherwise. - Complete verifies ownership before deleting and uses a UID delete precondition, so a worker that lost its lease cannot delete a job another worker is now running. Mismatch is reported as NotFound, which the cleanup controller already tolerates. - leaseRenewalLoop treats ErrLeaseLost as terminal and aborts immediately instead of retrying for ~90s while both pods run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Provisioning: distinguish lease-loss log lines in renewal loop Split the combined terminal-abort log into two messages so operators can tell a takeover (another worker re-claimed the same job name) apart from the job no longer existing (deleted or reaped before renewal). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Provisioning: address review - guard rollback and drop owner token from history - Rollback now verifies the refetched job's UID and owner token still match the claim we placed before stripping it. Job names are deterministic, so without this the rollback on a lost-lease path could un-claim a re-created job another worker is running, reintroducing duplicate execution. (Codex P1) - Complete now also clears the claim-owner label from the passed-in job, so the per-claim owner token does not leak into archived historic jobs. (Copilot) - Add a regression test for the rollback ownership guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Race condition / Authorization bypass prevention

Confidence: HIGH

Reasoning:

The patch introduces an owner token and verifies ownership (UID + owner token) before renewing leases or completing (deleting) jobs. This prevents a race where multiple workers could claim and operate on the same deterministic job name, potentially causing duplicate execution or unauthorized deletions. It also treats lease loss as terminal to avoid coexistence of two workers on the same job. This mitigates race conditions with security implications in provisioning/mutual exclusion.

Verification Assessment

Vulnerability Type: Race condition / Authorization bypass in provisioning job mutual exclusion

Confidence: HIGH

Affected Versions: 12.4.0 and earlier in the Grafana provisioning jobs feature (v0alpha1)

Code Diff

diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index 83d104eb5b8a..e23007d2a750 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -294,9 +294,20 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, if err != nil { consecutiveFailures++ - if apierrors.IsNotFound(err) || - strings.Contains(err.Error(), "job no longer exists") { - logger.Error("job no longer exists - lease expired", "error", err) + // Both cases below are terminal: continuing to run would mean two workers + // process the same job. Abort immediately rather than retrying, which would + // only stomp the new owner's claim. + + // Another worker now owns the claim (job reaped and re-claimed on the same name). + if errors.Is(err, ErrLeaseLost) { + logger.Error("lease taken over by another worker - aborting job", "error", err) + close(leaseExpired) + return + } + + // The job no longer exists in the store (deleted or reaped before renewal). + if apierrors.IsNotFound(err) || strings.Contains(err.Error(), "job no longer exists") { + logger.Error("job no longer exists - aborting job", "error", err) close(leaseExpired) return } diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index 0a1e5c4be4e0..5f1f9a78341e 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -20,6 +20,7 @@ import ( client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/util" "github.com/prometheus/client_golang/prometheus" ) @@ -28,12 +29,24 @@ const ( // The label must be formatted as milliseconds from Epoch. This grants a natural ordering, allowing for less-than operators in label selectors. // The natural ordering would be broken if the number rolls over into 1 more digit. This won't happen before Nov, 2286. LabelJobClaim = "provisioning.grafana.app/claim" + // LabelJobClaimOwner is a token unique to a single claim, identifying which worker owns it. + // Job names are deterministic (repository + action), so a reaped job and its re-created + // namesake share a name. Without an owner token, a worker cannot tell its own claim apart + // from a fresh claim placed by another worker on the same name, and would renew/complete a + // job it no longer owns -- leading to two workers running the same job. The token lets + // RenewLease and Complete verify the claim in the store is still the one we placed. + LabelJobClaimOwner = "provisioning.grafana.app/claim-owner" // LabelRepository contains the repository name as a label. This allows for label selectors to find the archived version of a job. LabelRepository = "provisioning.grafana.app/repository" // LabelJobOriginalUID contains the Job's original uid as a label. This allows for label selectors to find the archived version of a job. LabelJobOriginalUID = "provisioning.grafana.app/original-uid" ) +// ErrLeaseLost indicates the job's claim in the store is no longer the one we placed: +// the job was reaped and re-created, or another worker has taken it over. A worker that +// sees this must stop processing immediately so two workers do not run the same job. +var ErrLeaseLost = errors.New("job lease lost: claimed by another worker") + var ErrNoJobs = &apierrors.StatusError{ ErrStatus: metav1.Status{ Status: metav1.StatusFailure, @@ -137,6 +150,7 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol job.Labels = make(map[string]string) } job.Labels[LabelJobClaim] = strconv.FormatInt(s.clock().UnixMilli(), 10) + job.Labels[LabelJobClaimOwner] = util.GenerateShortUID() s.queueMetrics.RecordWaitTime(string(job.Spec.Action), s.clock().Sub(job.CreationTimestamp.Time).Seconds()) // Set up the provisioning identity for this namespace @@ -193,9 +207,19 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol return } + // Only roll back if the job in the store is still the one we claimed. Job names are + // deterministic, so this same name may now be a re-created job claimed by another + // worker. Stripping its claim would hand that worker's job to a third one and + // reintroduce duplicate execution, so leave it alone. + if refetched.UID != updatedJob.UID || refetched.Labels[LabelJobClaimOwner] != updatedJob.Labels[LabelJobClaimOwner] { + logger.Info("claim no longer owned by this worker - skipping rollback") + return + } + // Rollback the claim. refetchedJob := refetched.DeepCopy() delete(refetchedJob.Labels, LabelJobClaim) + delete(refetchedJob.Labels, LabelJobClaimOwner) refetchedJob.Status.State = provisioning.JobStatePending timeoutCtx, cancel = context.WithTimeout(ctx, 5*time.Second) @@ -306,23 +330,44 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err) } + // Verify we still own the job before deleting it. Job names are deterministic, so a + // reaped job and its re-created namesake share a name. Deleting purely by name would let + // a worker that has lost its lease delete the job another worker is now running. A matching + // UID proves it is the same object we claimed, and a matching owner token proves the claim + // is still ours. If the job is gone or a different incarnation exists, report NotFound so + // callers treat it as already cleaned up. + latest, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{}) + if err != nil { + span.RecordError(err) + return apifmt.Errorf("failed to get job '%s' in '%s' for completion: %w", job.GetName(), job.GetNamespace(), err) + } + if latest.UID != job.UID || latest.Labels[LabelJobClaimOwner] != job.Labels[LabelJobClaimOwner] { + logger.Info("job no longer owned by this worker - skipping completion") + return apierrors.NewNotFound(provisioning.JobResourceInfo.GroupResource(), job.GetName()) + } + // Delete the job from the active job store. // Callers are responsible for writing the job to history after calling this. // - // We will assume that the caller is the claimant. If this is not true, an error is returned. - // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to delete it. - err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{}) + // The UID precondition makes the delete atomic against the ownership check above: if the + // object is replaced by a namesake between the Get and the Delete, the precondition fails. + uid := job.UID + err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{ + Preconditions: &metav1.Preconditions{UID: &uid}, + }) if err != nil { span.RecordError(err) return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } logger.Debug("deleted job from job store") - // We need to remove the claim label before moving the job to the historic job store. + // We need to remove the claim labels before moving the job to the historic job store, + // so the per-claim owner token does not leak into the archived object. if job.Labels == nil { job.Labels = make(map[string]string) } delete(job.Labels, LabelJobClaim) + delete(job.Labels, LabelJobClaimOwner) s.queueMetrics.DecreaseQueueSize(string(job.Spec.Action)) logger.Debug("complete job complete") @@ -419,14 +464,23 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) return apifmt.Errorf("failed to fetch job for lease renewal '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err) } - // Verify we still own the lease - if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" { - err := apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace()) + // Verify we still own the lease. Checking that the job is claimed is not enough: + // job names are deterministic, so the claim in the store may belong to a different + // worker that took over after ours was reaped. A matching UID proves it is the same + // object we claimed (a re-created namesake gets a fresh UID), and a matching owner + // token proves the claim is still the one we placed. If either differs, we have lost + // the lease and must not renew it. + owner := job.Labels[LabelJobClaimOwner] + if latestJob.Labels == nil || + latestJob.Labels[LabelJobClaim] == "" || + latestJob.Labels[LabelJobClaimOwner] != owner || + latestJob.UID != job.UID { + err := apifmt.Errorf("lease lost for job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), ErrLeaseLost) span.RecordError(err) return err } - // Update the claim timestamp to current time + // Update the claim timestamp to current time, preserving our owner token. updatedJob := latestJob.DeepCopy() updatedJob.Labels[LabelJobClaim] = strconv.FormatInt(s.clock().UnixMilli(), 10) diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore_test.go b/pkg/registry/apis/provisioning/jobs/persistentstore_test.go index 27357724c8f3..cb3c5755532a 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore_test.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore_test.go @@ -2,11 +2,14 @@ package jobs import ( "context" + "errors" "testing" "time" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" @@ -15,6 +18,223 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ) +func newTestStore(client provisioningv0alpha1.ProvisioningV0alpha1Interface) *persistentStore { + return &persistentStore{ + client: client, + clock: time.Now, + expiry: 30 * time.Second, + queueMetrics: RegisterQueueMetrics(prometheus.NewPedanticRegistry()), + } +} + +// TestClaim_StampsOwnerToken verifies that claiming a job stamps a unique owner +// token alongside the claim timestamp, so ownership can be verified later. +func TestClaim_StampsOwnerToken(t *testing.T) { + fakeClient := newTestClientset() + store := newTestStore(fakeClient) + + ctx, _, err := identity.WithProvisioningIdentity(context.Background(), "stacks-123") + require.NoError(t, err) + + _, err = fakeClient.Jobs("stacks-123").Create(ctx, &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "stacks-123"}, + Spec: provisioning.JobSpec{Repository: "test-repo", Action: provisioning.JobActionPull}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + claimed, rollback, err := store.Claim(ctx) + require.NoError(t, err) + require.NotNil(t, claimed) + defer rollback() + + assert.NotEmpty(t, claimed.Labels[LabelJobClaim], "claim timestamp should be set") + assert.NotEmpty(t, claimed.Labels[LabelJobClaimOwner], "claim owner token should be set") +} + +// TestClaim_RollbackSkipsJobOwnedByAnother verifies that the claim rollback does not +// strip the claim from a job that is now owned by another worker. Job names are +// deterministic, so by the time we roll back, the same name may be a re-created job +// another worker is running; clearing its claim would reintroduce duplicate execution. +func TestClaim_RollbackSkipsJobOwnedByAnother(t *testing.T) { + fakeClient := newTestClientset() + store := newTestStore(fakeClient) + + ctx, _, err := identity.WithProvisioningIdentity(context.Background(), "stacks-123") + require.NoError(t, err) + + _, err = fakeClient.Jobs("stacks-123").Create(ctx, &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "stacks-123"}, + Spec: provisioning.JobSpec{Repository: "test-repo", Action: provisioning.JobActionPull}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + _, rollback, err := store.Claim(ctx) + require.NoError(t, err) + + // Simulate another worker taking over the same job name after our claim. + taken, err := fakeClient.Jobs("stacks-123").Get(ctx, "test-job", metav1.GetOptions{}) + require.NoError(t, err) + taken.Labels[LabelJobClaimOwner] = "another-worker" + _, err = fakeClient.Jobs("stacks-123").Update(ctx, taken, metav1.UpdateOptions{}) + require.NoError(t, err) + + // Rolling back our (now lost) claim must not disturb the other worker's job. + rollback() + + after, err := fakeClient.Jobs("stacks-123").Get(ctx, "test-job", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "another-worker", after.Labels[LabelJobClaimOwner], "rollback must not strip another worker's claim") + assert.NotEmpty(t, after.Labels[LabelJobClaim], "rollback must not clear the active claim timestamp") +} + +// TestRenewLease_LostToAnotherOwner verifies that a worker cannot renew a claim +// that now belongs to a different owner (same job name, different owner token). +// This is the core protection against two workers processing the same job: once +// a job is reaped and re-claimed by another worker, the original worker's renewal +// must fail with ErrLeaseLost so it aborts instead of continuing to run. +func TestRenewLease_LostToAnotherOwner(t *testing.T) { + fakeClient := newTestClientset() + store := newTestStore(fakeClient) + + ctx, _, err := identity.WithProvisioningIdentity(context.Background(), "stacks-123") + require.NoError(t, err) + + // The store's job is claimed by owner-B. + created, err := fakeClient.Jobs("stacks-123").Create(ctx, &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "stacks-123", + UID: "uid-1", + Labels: map[string]string{ + LabelJobClaim: "1000000000000", + LabelJobClaimOwner: "owner-B", + }, + }, + Spec: provisioning.JobSpec{Repository: "test-repo", Action: provisioning.JobActionPull}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + // We hold a claim with owner-A on the same job name/UID. + ours := created.DeepCopy() + ours.Labels[LabelJobClaimOwner] = "owner-A" + + err = store.RenewLease(ctx, ours) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrLeaseLost), "expected ErrLeaseLost, got %v", err) +} + +// TestRenewLease_LostToReincarnatedJob verifies that a matching owner token is not +// enough: if the object was deleted and re-created under the same name (new UID), +// renewal must still fail. +func TestRenewLease_LostToReincarnatedJob(t *testing ... [truncated]
← Back to Alerts View on GitHub →