Repository: akuity/kargo (go)

Issue:
bug: need to verify freight being promoted is wanted
Toward the beginning of the promotion process, we do validate that the Freight being promoted is _available_ to the Stage it is being promoted to:

https://github.com/akuity/kargo/blob/fd47846bcddb26e340abc0a1ccb52c44e43d8243/internal/controller/promotions/promotions.go#L471-L478

There is, however, an edge case wherein a piece of Freight could be _available_ to the Stage, but not _wanted._

It would/could happen if a piece of Freight from a particular origin had been manually approved for the Stage in question and since that time the Stage was edited to no longer request Freight from that origin.

Such a promotion should not proceed.

Note: @hiddeco has a recollection of being unable to reproduce this. I will attempt to do so when time permits, but if he's right, we'll just close this.

Pull request:
fix: improve freight availability checks
Fixes #2365 

After approaching this problem a few different ways, the thing that came out cleanest was treating the question of whether a piece of Freight is _wanted_ by a given Stage as just another dimension of whether that Freight is _available_ to that Stage.

1b3d577e491eddf6e91b7e463a41ae8c02399419 is some pre-requisite refactoring that made the more prescient refactoring and enhancements in 75d6fcc7d66da56006be5fc371e8c15f965d0de9 a bit easier.

6753bad5a0cb046e891e320508acce410a00356b just updates existing usage of the refactored availability check accordingly.

2559c0c2323e5c544966df8361267e1580e1bc4b adds the availability check to the Promo creation webhook so that availability checks aren't bypassed when using `kubectl` instead of `kargo` or the UI.

Patch:
diff --git a/Makefile b/Makefile
index 7053920f..c23bb58d 100644
--- a/Makefile
+++ b/Makefile
@@ -19,7 +19,7 @@ LOCAL_REG_PORT			?= 5000
 BASE_IMAGE 			?= localhost:$(LOCAL_REG_PORT)/$(IMAGE_REPO)-base
 IMAGE_TAG 			?= dev
 IMAGE_PUSH 			?= false
-IMAGE_PLATFORMS 	=
+IMAGE_PLATFORMS 	?=
 DOCKER_BUILD_OPTS 	=
 
 DOCS_PORT 				?= 3000
diff --git a/api/v1alpha1/freight_helpers.go b/api/v1alpha1/freight_helpers.go
index 7888d981..a3707dbf 100644
--- a/api/v1alpha1/freight_helpers.go
+++ b/api/v1alpha1/freight_helpers.go
@@ -86,34 +86,24 @@ func GetFreightByAlias(
 }
 
 // IsFreightAvailable answers whether the specified Freight is available to the
-// specified Stage having the specified upstream stages. Freight is available
-// if:
-//
-//  1. No upstreamStages are specified
-//     OR
-//  2. The Freight has been verified in ANY of the specified upstream stages
-//     OR
-//  3. The Freight is approved for the specified stage
-//
-// Note: The rationale for returning true when no upstream stages are specified
-// is that some Stages have no upstream Stages (e.g. a Stage that subscribes to
-// a Warehouse), so ANY Freight is available to such a Stage.
-func IsFreightAvailable(
-	freight *Freight,
-	stage string,
-	upstreamStages []string,
-) bool {
-	if len(upstreamStages) == 0 {
-		return true
+// specified Stage.
+func IsFreightAvailable(stage *Stage, freight *Freight) bool {
+	if stage == nil || freight == nil || stage.Namespace != freight.Namespace {
+		return false
 	}
-	for _, stage := range upstreamStages {
-		if _, ok := freight.Status.VerifiedIn[stage]; ok {
-			return true
-		}
+	if _, approved := freight.Status.ApprovedFor[stage.Name]; approved {
+		return true
 	}
-	if stage != "" {
-		if _, ok := freight.Status.ApprovedFor[stage]; ok {
-			return true
+	for _, req := range stage.Spec.RequestedFreight {
+		if freight.Origin.Equals(&req.Origin) {
+			if req.Sources.Direct {
+				return true
+			}
+			for _, source := range req.Sources.Stages {
+				if _, verified := freight.Status.VerifiedIn[source]; verified {
+					return true
+				}
+			}
 		}
 	}
 	return false
diff --git a/docs/docs/30-how-to-guides/20-managing-credentials.md b/docs/docs/30-how-to-guides/20-managing-credentials.md
index 1e85f742..a3da210c 100644
--- a/docs/docs/30-how-to-guides/20-managing-credentials.md
+++ b/docs/docs/30-how-to-guides/20-managing-credentials.md
@@ -56,9 +56,11 @@ field), MUST contain the following keys:
 
 * `repoURL`: The full URL of the repository the credentials are for.
 
-* `username`: The username to use when authenticating to the repository.
+* Either:
 
-* `password`: A password or personal access token.
+  * `username`: The username to use when authenticating to the repository.
+
+  * `password`: A password or personal access token.
 
     :::info
     If the value of the `password` key is a personal access token, the value of
@@ -66,6 +68,12 @@ field), MUST contain the following keys:
     repository's documentation for more information.
     :::
 
+  OR:
+
+  * `sshPrivateKey`: A PEM-encoded SSH private key. Applicable to Git
+    repositories only.
+    
+
 Optionally, the following keys may also be included:
 
 * `repoURLIsRegex`: Set this to `true` if the value of the `repoURL` key
diff --git a/internal/api/promote_downstream_v1alpha1.go b/internal/api/promote_downstream_v1alpha1.go
index aca59c27..888194f9 100644
--- a/internal/api/promote_downstream_v1alpha1.go
+++ b/internal/api/promote_downstream_v1alpha1.go
@@ -92,22 +92,26 @@ func (s *server) PromoteDownstream(
 		}
 		return nil, connect.NewError(connect.CodeNotFound, err)
 	}
-	if !s.isFreightAvailableFn(
-		freight,
-		"",                  // approved for not considered
-		[]string{stageName}, // verified in
-	) {
+
+	var directAllowed bool
+	for _, req := range stage.Spec.RequestedFreight {
+		if req.Origin.Equals(&freight.Origin) && req.Sources.Direct {
+			directAllowed = true
+			break
+		}
+	}
+	if _, verified := freight.Status.VerifiedIn[stage.Name]; !verified && !directAllowed {
 		return nil, connect.NewError(
 			connect.CodeInvalidArgument,
 			fmt.Errorf(
-				"Freight %q is not available to Stage %q",
+				"Freight %q is not available to Stages down stream from %q",
 				freightName,
 				stageName,
 			),
 		)
 	}
 
-	downstreams, err := s.findDownstreamStagesFn(ctx, stage)
+	downstreams, err := s.findDownstreamStagesFn(ctx, stage, freight.Origin)
 	if err != nil {
 		return nil, fmt.Errorf("find downstream stages: %w", err)
 	}
@@ -160,9 +164,13 @@ func (s *server) PromoteDownstream(
 }
 
 // findDownstreamStages returns a list of Stages that are immediately downstream
-// from the given Stage.
+// from the given Stage and request Freight from the given origin.
 // TODO: this could be powered by an index.
-func (s *server) findDownstreamStages(ctx context.Context, stage *kargoapi.Stage) ([]kargoapi.Stage, error) {
+func (s *server) findDownstreamStages(
+	ctx context.Context,
+	stage *kargoapi.Stage,
+	origin kargoapi.FreightOrigin,
+) ([]kargoapi.Stage, error) {
 	var allStages kargoapi.StageList
 	if err := s.client.List(ctx, &allStages, client.InNamespace(stage.Namespace)); err != nil {
 		return nil, connect.NewError(connect.CodeInternal, err)
@@ -170,6 +178,9 @@ func (s *server) findDownstreamStages(ctx context.Context, stage *kargoapi.Stage
 	var downstreams []kargoapi.Stage
 	for _, s := range allStages.Items {
 		for _, req := range s.Spec.RequestedFreight {
+			if !req.Origin.Equals(&origin) {
+				continue
+			}
 			for _, upstream := range req.Sources.Stages {
 				if upstream == stage.Name {
 					downstreams = append(downstreams, s)
diff --git a/internal/api/promote_to_stage_v1alpha1.go b/internal/api/promote_to_stage_v1alpha1.go
index d79a0321..97c3c46f 100644
--- a/internal/api/promote_to_stage_v1alpha1.go
+++ b/internal/api/promote_to_stage_v1alpha1.go
@@ -86,14 +86,7 @@ func (s *server) PromoteToStage(
 		return nil, connect.NewError(connect.CodeNotFound, err)
 	}
 
-	var upstreamStages []string
-	for _, req := range stage.Spec.RequestedFreight {
-		if req.Origin.Equals(&freight.Origin) {
-			upstreamStages = req.Sources.Stages
-			break
-		}
-	}
-	if !s.isFreightAvailableFn(freight, stage.Name, upstreamStages) {
+	if !s.isFreightAvailableFn(stage, freight) {
 		return nil, connect.NewError(
 			connect.CodeInvalidArgument,
 			fmt.Errorf(
diff --git a/internal/api/server.go b/internal/api/server.go
index b82daa2c..0a53283f 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -76,11 +76,7 @@ type server struct {
 		name string,
 		alias string,
 	) (*kargoapi.Freight, error)
-	isFreightAvailableFn func(
-		freight *kargoapi.Freight,
-		stage string,
-		upstreamStages []string,
-	) bool
+	isFreightAvailableFn func(*kargoapi.Stage, *kargoapi.Freight) bool
 
 	// Common Promotions:
 	createPromotionFn func(
@@ -90,7 +86,11 @@ type server struct {
 	) error
 
 	// Promote downstream:
-	findDownstreamStagesFn func(ctx context.Context, stage *kargoapi.Stage) ([]kargoapi.Stage, error)
+	findDownstreamStagesFn func(
+		context.Context,
+		*kargoapi.Stage,
+		kargoapi.FreightOrigin,
+	) ([]kargoapi.Stage, error)
 
 	// QueryFreight API:
 	listFreightFn func(
diff --git a/internal/cli/cmd/login/login.go b/internal/cli/cmd/login/login.go
index 3fe40ad4..0c8422e5 100644
--- a/internal/cli/cmd/login/login.go
+++ b/internal/cli/cmd/login/login.go
@@ -73,7 +73,7 @@ kargo login https://kargo.example.com --admin
 kargo login https://kargo.example.com --kubeconfig
 
 # Log in using the local kubeconfig and ignore cert warnings
-kargo login https://kargo.example.com --kubeconfig --insecure-tls
+kargo login https://kargo.example.com --kubeconfig --insecure-skip-tls-verify
 `),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cmdOpts.complete(args)
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 87f53b86..71a327ae 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"slices"
 	"strconv"
 	"sync"
 	"time"
@@ -466,15 +465,8 @@ func (r *reconciler) promote(
 	if targetFreight == nil {
 		return nil, fmt.Errorf("Freight %q not found in namespace %q", promo.Spec.Freight, promo.Namespace)
 	}
-	var upstreams []string
-	for _, req := range stage.Spec.RequestedFreight {
-		upstreams = append(upstreams, req.Sources.Stages...)
-	}
-	// De-dupe upstreams
-	slices.Sort(upstreams)
-	upstreams = slices.Compact(upstreams)
 
-	if !kargoapi.IsFreightAvailable(targetFreight, stageName, upstreams) {
+	if !kargoapi.IsFreightAvailable(stage, targetFreight) {
 		return nil, fmt.Errorf(
 			"Freight %q is not available to Stage %q in namespace %q",
 			promo.Spec.Freight,
diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/promotion/webhook.go
index ecc0be73..1ef305f2 100644
--- a/internal/webhook/promotion/webhook.go
+++ b/internal/webhook/promotion/webhook.go
@@ -221,15 +221,38 @@ func (w *webhook) ValidateCreate(
 		return nil, fmt.Errorf("get admission request from context: %w", err)
 	}
 
+	stage, err := w.getStageFn(ctx, w.client, types.NamespacedName{
+		Namespace: promo.Namespace,
+		Name:      promo.Spec.Stage,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("get stage: %w", err)
+	}
+
+	freight, err := w.getFreightFn(ctx, w.client, types.NamespacedName{
+		Namespace: promo.Namespace,
+		Name:      promo.Spec.Freight,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("get freight: %w", err)
+	}
+
+	if !kargoapi.IsFreightAvailable(stage, freight) {
+		return nil, apierrors.NewInvalid(
+			promotionGroupKind,
+			promo.Name,
+			field.ErrorList{
+				field.Invalid(
+					field.NewPath("spec", "freight"),
+					promo.Spec.Freight,
+					"Freight is not available to this Stage",
+				),
+			},
+		)
+	}
+
 	// Record Promotion created event if the request doesn't come from Kargo controlplane
 	if !w.isRequestFromKargoControlplaneFn(req) {
-		freight, err := w.getFreightFn(ctx, w.client, types.NamespacedName{
-			Namespace: promo.Namespace,
-			Name:      promo.Spec.Freight,
-		})
-		if err != nil {
-			return nil, fmt.Errorf("get freight: %w", err)
-		}
 		w.recordPromotionCreatedEvent(ctx, req, promo, freight)
 	}
 	return nil, nil


Test patch:
diff --git a/api/v1alpha1/freight_helpers_test.go b/api/v1alpha1/freight_helpers_test.go
index a9985852..5d29b200 100644
--- a/api/v1alpha1/freight_helpers_test.go
+++ b/api/v1alpha1/freight_helpers_test.go
@@ -116,54 +116,163 @@ func TestGetFreightByAlias(t *testing.T) {
 }
 
 func TestIsFreightAvailable(t *testing.T) {
-	testFreight := &Freight{
-		Status: FreightStatus{
-			VerifiedIn: map[string]VerifiedStage{
-				"fake-stage-1": {},
+	const testNamespace = "fake-namespace"
+	const testWarehouse = "fake-warehouse"
+	const testStage = "fake-stage"
+
+	testCases := []struct {
+		name     string
+		stage    *Stage
+		Freight  *Freight
+		expected bool
+	}{
+		{
+			name:     "stage is nil",
+			expected: false,
+		},
+		{
+			name:     "freight is nil",
+			expected: false,
+		},
+		{
+			name: "stage and freight are in different namespaces",
+			stage: &Stage{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
 			},
-			ApprovedFor: map[string]ApprovedStage{
-				"fake-stage-2": {},
+			Freight: &Freight{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "wrong-namespace",
+				},
 			},
+			expected: false,
 		},
-	}
-	testCases := []struct {
-		name           string
-		stage          string
-		upstreamStages []string
-		available      bool
-	}{
 		{
-			name:      "no upstream Stages specified",
-			available: true,
+			name: "freight is approved for stage",
+			stage: &Stage{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+					Name:      testStage,
+				},
+			},
+			Freight: &Freight{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+					Name:      testStage,
+				},
+				Status: FreightStatus{
+					ApprovedFor: map[string]ApprovedStage{
+						testStage: {},
+					},
+				},
+			},
+			expected: true,
 		},
 		{
-			name:           "verified in an upstream Stage",
-			upstreamStages: []string{"fake-stage-1"},
-			available:      true,
+			name: "stage accepts freight direct from origin",
+			stage: &Stage{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Spec: StageSpec{
+					RequestedFreight: []FreightRequest{{
+						Origin: FreightOrigin{
+							Kind: FreightOriginKindWarehouse,
+							Name: testWarehouse,
+						},
+						Sources: FreightSources{
+							Direct: true,
+						},
+					}},
+				},
+			},
+			Freight: &Freight{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Origin: FreightOrigin{
+					Kind: FreightOriginKindWarehouse,
+					Name: testWarehouse,
+				},
+			},
+			expected: true,
 		},
 		{
-			name:           "approved for Stage",
-			stage:          "fake-stage-2",
-			upstreamStages: []string{"fake-stage-3"},
-			available:      true,
+			name: "freight is verified in an upstream stage",
+			stage: &Stage{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Spec: StageSpec{
+					RequestedFreight: []FreightRequest{{
+						Origin: FreightOrigin{
+							Kind: FreightOriginKindWarehouse,
+							Name: testWarehouse,
+						},
+						Sources: FreightSources{
+							Stages: []string{"upstream-stage"},
+						},
+					}},
+				},
+			},
+			Freight: &Freight{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Origin: FreightOrigin{
+					Kind: FreightOriginKindWarehouse,
+					Name: testWarehouse,
+				},
+				Status: FreightStatus{
+					VerifiedIn: map[string]VerifiedStage{
+						"upstream-stage": {},
+					},
+				},
+			},
+			expected: true,
 		},
 		{
-			name:           "unavailable",
-			stage:          "fake-stage-3",
-			upstreamStages: []string{"upstream-stage-2"},
-			available:      false,
+			name: "freight from origin not requested",
+			stage: &Stage{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Spec: StageSpec{
+					RequestedFreight: []FreightRequest{{
+						Origin: FreightOrigin{
+							Kind: FreightOriginKindWarehouse,
+							Name: testWarehouse,
+						},
+						Sources: FreightSources{
+							Stages: []string{"upstream-stage"},
+						},
+					}},
+				},
+			},
+			Freight: &Freight{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: testNamespace,
+				},
+				Origin: FreightOrigin{
+					Kind: FreightOriginKindWarehouse,
+					Name: "wrong-warehouse",
+				},
+				Status: FreightStatus{
+					VerifiedIn: map[string]VerifiedStage{
+						"upstream-stage": {},
+					},
+				},
+			},
+			expected: false,
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
 			require.Equal(
 				t,
-				testCase.available,
-				IsFreightAvailable(
-					testFreight,
-					testCase.stage,
-					testCase.upstreamStages,
-				),
+				testCase.expected,
+				IsFreightAvailable(testCase.stage, testCase.Freight),
 			)
 		})
 	}
diff --git a/internal/api/promote_downstream_v1alpha1_test.go b/internal/api/promote_downstream_v1alpha1_test.go
index b169a4df..1c30b91f 100644
--- a/internal/api/promote_downstream_v1alpha1_test.go
+++ b/internal/api/promote_downstream_v1alpha1_test.go
@@ -8,6 +8,7 @@ import (
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -246,9 +247,6 @@ func TestPromoteDownstream(t *testing.T) {
 				) (*kargoapi.Freight, error) {
 					return &kargoapi.Freight{}, nil
 				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return false
-				},
 			},
 			assertions: func(
 				t *testing.T,
@@ -281,6 +279,10 @@ func TestPromoteDownstream(t *testing.T) {
 					types.NamespacedName,
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "fake-project",
+							Name:      "fake-stage",
+						},
 						Spec: testStageSpec,
 					}, nil
 				},
@@ -289,12 +291,19 @@ func TestPromoteDownstream(t *testing.T) {
 					client.Client,
 					string, string, string,
 				) (*kargoapi.Freight, error) {
-					return &kargoapi.Freight{}, nil
-				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return true
+					return &kargoapi.Freight{
+						Status: kargoapi.FreightStatus{
+							VerifiedIn: map[string]kargoapi.VerifiedStage{
+								"fake-stage": {},
+							},
+						},
+					}, nil
 				},
-				findDownstreamStagesFn: func(context.Context, *kargoapi.Stage) ([]kargoapi.Stage, error) {
+				findDownstreamStagesFn: func(
+					context.Context,
+					*kargoapi.Stage,
+					kargoapi.FreightOrigin,
+				) ([]kargoapi.Stage, error) {
 					return nil, errors.New("something went wrong")
 				},
 			},
@@ -325,6 +334,10 @@ func TestPromoteDownstream(t *testing.T) {
 					types.NamespacedName,
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "fake-project",
+							Name:      "fake-stage",
+						},
 						Spec: testStageSpec,
 					}, nil
 				},
@@ -333,12 +346,19 @@ func TestPromoteDownstream(t *testing.T) {
 					client.Client,
 					string, string, string,
 				) (*kargoapi.Freight, error) {
-					return &kargoapi.Freight{}, nil
-				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return true
+					return &kargoapi.Freight{
+						Status: kargoapi.FreightStatus{
+							VerifiedIn: map[string]kargoapi.VerifiedStage{
+								"fake-stage": {},
+							},
+						},
+					}, nil
 				},
-				findDownstreamStagesFn: func(context.Context, *kargoapi.Stage) ([]kargoapi.Stage, error) {
+				findDownstreamStagesFn: func(
+					context.Context,
+					*kargoapi.Stage,
+					kargoapi.FreightOrigin,
+				) ([]kargoapi.Stage, error) {
 					return nil, nil
 				},
 			},
@@ -373,6 +393,10 @@ func TestPromoteDownstream(t *testing.T) {
 					types.NamespacedName,
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "fake-project",
+							Name:      "fake-stage",
+						},
 						Spec: testStageSpec,
 					}, nil
 				},
@@ -383,12 +407,19 @@ func TestPromoteDownstream(t *testing.T) {
 					string,
 					string,
 				) (*kargoapi.Freight, error) {
-					return &kargoapi.Freight{}, nil
-				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return true
+					return &kargoapi.Freight{
+						Status: kargoapi.FreightStatus{
+							VerifiedIn: map[string]kargoapi.VerifiedStage{
+								"fake-stage": {},
+							},
+						},
+					}, nil
 				},
-				findDownstreamStagesFn: func(context.Context, *kargoapi.Stage) ([]kargoapi.Stage, error) {
+				findDownstreamStagesFn: func(
+					context.Context,
+					*kargoapi.Stage,
+					kargoapi.FreightOrigin,
+				) ([]kargoapi.Stage, error) {
 					return []kargoapi.Stage{{}}, nil
 				},
 				authorizeFn: func(
@@ -428,6 +459,10 @@ func TestPromoteDownstream(t *testing.T) {
 					types.NamespacedName,
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "fake-project",
+							Name:      "fake-stage",
+						},
 						Spec: testStageSpec,
 					}, nil
 				},
@@ -436,12 +471,19 @@ func TestPromoteDownstream(t *testing.T) {
 					client.Client,
 					string, string, string,
 				) (*kargoapi.Freight, error) {
-					return &kargoapi.Freight{}, nil
-				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return true
+					return &kargoapi.Freight{
+						Status: kargoapi.FreightStatus{
+							VerifiedIn: map[string]kargoapi.VerifiedStage{
+								"fake-stage": {},
+							},
+						},
+					}, nil
 				},
-				findDownstreamStagesFn: func(context.Context, *kargoapi.Stage) ([]kargoapi.Stage, error) {
+				findDownstreamStagesFn: func(
+					context.Context,
+					*kargoapi.Stage,
+					kargoapi.FreightOrigin,
+				) ([]kargoapi.Stage, error) {
 					return []kargoapi.Stage{
 						{
 							Spec: kargoapi.StageSpec{
@@ -501,6 +543,10 @@ func TestPromoteDownstream(t *testing.T) {
 					types.NamespacedName,
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
+						ObjectMeta: metav1.ObjectMeta{
+							Namespace: "fake-project",
+							Name:      "fake-stage",
+						},
 						Spec: testStageSpec,
 					}, nil
 				},
@@ -509,12 +555,19 @@ func TestPromoteDownstream(t *testing.T) {
 					client.Client,
 					string, string, string,
 				) (*kargoapi.Freight, error) {
-					return &kargoapi.Freight{}, nil
-				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
-					return true
+					return &kargoapi.Freight{
+						Status: kargoapi.FreightStatus{
+							VerifiedIn: map[string]kargoapi.VerifiedStage{
+								"fake-stage": {},
+							},
+						},
+					}, nil
 				},
-				findDownstreamStagesFn: func(context.Context, *kargoapi.Stage) ([]kargoapi.Stage, error) {
+				findDownstreamStagesFn: func(
+					context.Context,
+					*kargoapi.Stage,
+					kargoapi.FreightOrigin,
+				) ([]kargoapi.Stage, error) {
 					return []kargoapi.Stage{
 						{
 							Spec: kargoapi.StageSpec{
diff --git a/internal/api/promote_to_stage_v1alpha1_test.go b/internal/api/promote_to_stage_v1alpha1_test.go
index f7724f04..99fb960a 100644
--- a/internal/api/promote_to_stage_v1alpha1_test.go
+++ b/internal/api/promote_to_stage_v1alpha1_test.go
@@ -247,7 +247,7 @@ func TestPromoteToStage(t *testing.T) {
 				) (*kargoapi.Freight, error) {
 					return &kargoapi.Freight{}, nil
 				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
+				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
 					return false
 				},
 			},
@@ -294,7 +294,7 @@ func TestPromoteToStage(t *testing.T) {
 				) (*kargoapi.Freight, error) {
 					return &kargoapi.Freight{}, nil
 				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
+				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
 					return true
 				},
 				authorizeFn: func(
@@ -344,7 +344,7 @@ func TestPromoteToStage(t *testing.T) {
 				) (*kargoapi.Freight, error) {
 					return &kargoapi.Freight{}, nil
 				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
+				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
 					return true
 				},
 				authorizeFn: func(
@@ -401,7 +401,7 @@ func TestPromoteToStage(t *testing.T) {
 				) (*kargoapi.Freight, error) {
 					return &kargoapi.Freight{}, nil
 				},
-				isFreightAvailableFn: func(*kargoapi.Freight, string, []string) bool {
+				isFreightAvailableFn: func(*kargoapi.Stage, *kargoapi.Freight) bool {
 					return true
 				},
 				authorizeFn: func(
diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/promotion/webhook_test.go
index e0e584cf..edf2b70f 100644
--- a/internal/webhook/promotion/webhook_test.go
+++ b/internal/webhook/promotion/webhook_test.go
@@ -151,6 +151,8 @@ func TestDefault(t *testing.T) {
 }
 
 func TestValidateCreate(t *testing.T) {
+	const testWarehouse = "fake-warehouse"
+
 	testCases := []struct {
 		name       string
 		webhook    *webhook
@@ -194,6 +196,103 @@ func TestValidateCreate(t *testing.T) {
 				require.Equal(t, "something went wrong", err.Error())
 			},
 		},
+		{
+			name: "error getting Stage",
+			webhook: &webhook{
+				validateProjectFn: func(
+					context.Context,
+					client.Client,
+					schema.GroupKind,
+					client.Object,
+				) error {
+					return nil
+				},
+				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return nil, errors.New("something went wrong")
+				},
+			},
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
+				require.ErrorContains(t, err, "get stage")
+				require.ErrorContains(t, err, "something went wrong")
+			},
+		},
+		{
+			name: "error getting Freight",
+			webhook: &webhook{
+				validateProjectFn: func(
+					context.Context,
+					client.Client,
+					schema.GroupKind,
+					client.Object,
+				) error {
+					return nil
+				},
+				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return &kargoapi.Stage{}, nil
+				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return nil, errors.New("something went wrong")
+				},
+			},
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
+				require.ErrorContains(t, err, "get freight")
+				require.ErrorContains(t, err, "something went wrong")
+			},
+		},
+		{
+			name: "Freight is not available to Stage",
+			webhook: &webhook{
+				validateProjectFn: func(
+					context.Context,
+					client.Client,
+					schema.GroupKind,
+					client.Object,
+				) error {
+					return nil
+				},
+				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
+					return nil
+				},
+				admissionRequestFromContextFn: admission.RequestFromContext,
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return &kargoapi.Stage{}, nil
+				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{}, nil
+				},
+			},
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
+				require.ErrorContains(t, err, "Freight is not available to this Stage")
+			},
+		},
 		{
 			name: "record promotion created event on non-controlplane request",
 			webhook: &webhook{
@@ -209,12 +308,34 @@ func TestValidateCreate(t *testing.T) {
 					return nil
 				},
 				admissionRequestFromContextFn: admission.RequestFromContext,
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return &kargoapi.Stage{
+						Spec: kargoapi.StageSpec{
+							RequestedFreight: []kargoapi.FreightRequest{{
+								Origin: kargoapi.FreightOrigin{
+									Kind: kargoapi.FreightOriginKindWarehouse,
+									Name: testWarehouse,
+								},
+								Sources: kargoapi.FreightSources{Direct: true},
+							}},
+						},
+					}, nil
+				},
 				getFreightFn: func(
 					context.Context,
 					client.Client,
 					types.NamespacedName,
 				) (*kargoapi.Freight, error) {
-					return nil, nil
+					return &kargoapi.Freight{
+						Origin: kargoapi.FreightOrigin{
+							Kind: kargoapi.FreightOriginKindWarehouse,
+							Name: testWarehouse,
+						},
+					}, nil
 				},
 				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
 					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),
@@ -244,6 +365,35 @@ func TestValidateCreate(t *testing.T) {
 				authorizeFn: func(context.Context, *kargoapi.Promotion, string) error {
 					return nil
 				},
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return &kargoapi.Stage{
+						Spec: kargoapi.StageSpec{
+							RequestedFreight: []kargoapi.FreightRequest{{
+								Origin: kargoapi.FreightOrigin{
+									Kind: kargoapi.FreightOriginKindWarehouse,
+									Name: "fake-warehouse",
+								},
+								Sources: kargoapi.FreightSources{Direct: true},
+							}},
+						},
+					}, nil
+				},
+				getFreightFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Freight, error) {
+					return &kargoapi.Freight{
+						Origin: kargoapi.FreightOrigin{
+							Kind: kargoapi.FreightOriginKindWarehouse,
+							Name: "fake-warehouse",
+						},
+					}, nil
+				},
 				admissionRequestFromContextFn: admission.RequestFromContext,
 				isRequestFromKargoControlplaneFn: libWebhook.IsRequestFromKargoControlplane(
 					regexp.MustCompile("^system:serviceaccount:kargo:(kargo-api|kargo-controller)$"),