Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package kargo-cli for openSUSE:Factory 
checked in at 2026-07-15 16:50:48
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/kargo-cli (Old)
 and      /work/SRC/openSUSE:Factory/.kargo-cli.new.1991 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "kargo-cli"

Wed Jul 15 16:50:48 2026 rev:57 rq:1365806 version:1.10.9

Changes:
--------
--- /work/SRC/openSUSE:Factory/kargo-cli/kargo-cli.changes      2026-07-01 
16:52:09.761419867 +0200
+++ /work/SRC/openSUSE:Factory/.kargo-cli.new.1991/kargo-cli.changes    
2026-07-15 17:08:06.288389444 +0200
@@ -1,0 +2,6 @@
+Wed Jul 15 05:11:41 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 1.10.9:
+  no CLI-related changes or dependency updates
+
+-------------------------------------------------------------------

Old:
----
  kargo-cli-1.10.8.obscpio

New:
----
  kargo-cli-1.10.9.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ kargo-cli.spec ++++++
--- /var/tmp/diff_new_pack.xC94eG/_old  2026-07-15 17:08:09.980514908 +0200
+++ /var/tmp/diff_new_pack.xC94eG/_new  2026-07-15 17:08:09.980514908 +0200
@@ -19,7 +19,7 @@
 %define executable_name kargo
 
 Name:           kargo-cli
-Version:        1.10.8
+Version:        1.10.9
 Release:        0
 Summary:        CLI for the Kubernetes Application lifecycle orchestration
 License:        Apache-2.0

++++++ _service ++++++
--- /var/tmp/diff_new_pack.xC94eG/_old  2026-07-15 17:08:10.020516268 +0200
+++ /var/tmp/diff_new_pack.xC94eG/_new  2026-07-15 17:08:10.020516268 +0200
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/akuity/kargo.git</param>
     <param name="scm">git</param>
     <param name="exclude">.git</param>
-    <param name="revision">refs/tags/v1.10.8</param>
+    <param name="revision">refs/tags/v1.10.9</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="versionrewrite-pattern">v(.*)</param>
     <param name="changesgenerate">enable</param>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.xC94eG/_old  2026-07-15 17:08:10.048517219 +0200
+++ /var/tmp/diff_new_pack.xC94eG/_new  2026-07-15 17:08:10.060517627 +0200
@@ -3,6 +3,6 @@
                 <param name="url">https://github.com/akuity/kargo</param>
               <param 
name="changesrevision">17077e406ed309fcc830d08776d9b9a47fe6e134</param></service><service
 name="tar_scm">
                 <param name="url">https://github.com/akuity/kargo.git</param>
-              <param 
name="changesrevision">50d29c10153965114f903645c83eadbe7bf58d3f</param></service></servicedata>
+              <param 
name="changesrevision">61a0cb831366ffc96594863bc9b1d8acf899d3da</param></service></servicedata>
 (No newline at EOF)
 

++++++ kargo-cli-1.10.8.obscpio -> kargo-cli-1.10.9.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
 
new/kargo-cli-1.10.9/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
--- 
old/kargo-cli-1.10.8/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
    2026-06-26 01:39:10.000000000 +0200
+++ 
new/kargo-cli-1.10.9/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
    2026-07-15 01:49:54.000000000 +0200
@@ -138,16 +138,16 @@
   config:
     path: ./out
     generateTargetBranch: true
-  - uses: git-open-pr
-    as: open-pr
-    config:
-      repoURL: https://github.com/example/repo.git
-      sourceBranch: ${{ outputs.push.branch }}
-      targetBranch: stage/${{ ctx.stage }}
-  - if: ${{ status('open-pr') != 'Skipped' }}
-    uses: git-wait-for-pr
-    as: wait-for-pr
-    config:
-      repoURL: https://github.com/example/repo.git
-      prNumber: ${{ outputs['open-pr'].pr.id }}
-```
\ No newline at end of file
+- uses: git-open-pr
+  as: open-pr
+  config:
+    repoURL: https://github.com/example/repo.git
+    sourceBranch: ${{ outputs.push.branch }}
+    targetBranch: stage/${{ ctx.stage }}
+- if: ${{ status('open-pr') != 'Skipped' }}
+  uses: git-wait-for-pr
+  as: wait-for-pr
+  config:
+    repoURL: https://github.com/example/repo.git
+    prNumber: ${{ outputs['open-pr'].pr.id }}
+```
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/kargo-cli-1.10.8/pkg/server/common.go 
new/kargo-cli-1.10.9/pkg/server/common.go
--- old/kargo-cli-1.10.8/pkg/server/common.go   2026-06-26 01:39:10.000000000 
+0200
+++ new/kargo-cli-1.10.9/pkg/server/common.go   2026-07-15 01:49:54.000000000 
+0200
@@ -19,6 +19,7 @@
        kargoapi "github.com/akuity/kargo/api/v1alpha1"
        "github.com/akuity/kargo/pkg/api"
        libhttp "github.com/akuity/kargo/pkg/http"
+       "github.com/akuity/kargo/pkg/server/rbac"
        "github.com/akuity/kargo/pkg/server/user"
 )
 
@@ -31,6 +32,12 @@
                Kind:    "Project",
        }
 
+       promotionGVK = schema.GroupVersionKind{
+               Group:   kargoapi.GroupVersion.Group,
+               Version: kargoapi.GroupVersion.Version,
+               Kind:    "Promotion",
+       }
+
        secretGVK = schema.GroupVersionKind{
                Group:   "",
                Version: "v1",
@@ -193,3 +200,55 @@
                obj.SetAnnotations(annotations)
        }
 }
+
+// authorizeResourceCreate enforces authorization checks that Kargo's 
authorizing
+// client cannot perform implicitly. That client authorizes only standard
+// Kubernetes verbs, but creating a Promotion additionally requires the custom
+// "promote" verb on the target Stage. Without this explicit check, a user
+// permitted to create Promotion resources could create one targeting any Stage
+// in a Project -- bypassing the per-Stage authorization the "promote" verb
+// exists to enforce -- because the Promotion mutating webhook's own "promote"
+// check evaluates the API server's identity, not the requesting user's, when
+// the API server creates the resource on the user's behalf.
+func (s *server) authorizeResourceCreate(
+       ctx context.Context,
+       obj *unstructured.Unstructured,
+) error {
+       if obj == nil || obj.GroupVersionKind() != promotionGVK {
+               return nil
+       }
+       stage, _, err := unstructured.NestedString(obj.Object, "spec", "stage")
+       if err != nil || stage == "" {
+               // A Promotion with no target Stage cannot promote anywhere; 
leave
+               // rejection of the malformed resource to normal validation.
+               return nil
+       }
+       return s.authorizeFn(
+               ctx,
+               "promote",
+               kargoapi.GroupVersion.WithResource("stages"),
+               "",
+               client.ObjectKey{Namespace: obj.GetNamespace(), Name: stage},
+       )
+}
+
+// verifyNoEscalation blocks a generic resource create or update from 
conferring
+// RBAC permissions the requester does not already hold. It supplies the
+// configured global ServiceAccount namespaces and delegates to
+// rbac.VerifyResourceNotEscalating.
+func (s *server) verifyNoEscalation(
+       ctx context.Context,
+       obj *unstructured.Unstructured,
+) error {
+       var globalNamespaces []string
+       if s.cfg.OIDCConfig != nil {
+               globalNamespaces = 
s.cfg.OIDCConfig.GlobalServiceAccountNamespaces
+       }
+       return rbac.VerifyResourceNotEscalating(
+               ctx,
+               s.client,
+               s.client.InternalClient(),
+               globalNamespaces,
+               obj,
+       )
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/kargo-cli-1.10.8/pkg/server/common_test.go 
new/kargo-cli-1.10.9/pkg/server/common_test.go
--- old/kargo-cli-1.10.8/pkg/server/common_test.go      2026-06-26 
01:39:10.000000000 +0200
+++ new/kargo-cli-1.10.9/pkg/server/common_test.go      2026-07-15 
01:49:54.000000000 +0200
@@ -1,7 +1,9 @@
 package server
 
 import (
+       "context"
        "encoding/json"
+       "errors"
        "strings"
        "testing"
 
@@ -11,6 +13,8 @@
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
        "k8s.io/apimachinery/pkg/runtime"
+       "k8s.io/apimachinery/pkg/runtime/schema"
+       "k8s.io/apimachinery/pkg/types"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
        sigyaml "sigs.k8s.io/yaml"
 
@@ -449,3 +453,128 @@
                t.Run(testCase.name, testCase.test)
        }
 }
+
+func Test_server_authorizeResourceCreate(t *testing.T) {
+       const project = "fake-project"
+       const stage = "fake-stage"
+
+       promotion := func() *unstructured.Unstructured {
+               return &unstructured.Unstructured{
+                       Object: map[string]any{
+                               "apiVersion": kargoapi.GroupVersion.String(),
+                               "kind":       "Promotion",
+                               "metadata": map[string]any{
+                                       "name":      "fake-promotion",
+                                       "namespace": project,
+                               },
+                               "spec": map[string]any{
+                                       "stage":   stage,
+                                       "freight": "fake-freight",
+                               },
+                       },
+               }
+       }
+
+       testCases := []struct {
+               name        string
+               obj         *unstructured.Unstructured
+               authorizeFn func(
+                       context.Context,
+                       string,
+                       schema.GroupVersionResource,
+                       string,
+                       types.NamespacedName,
+               ) error
+               assert func(*testing.T, error, bool)
+       }{
+               {
+                       name: "non-Promotion resource is not subject to the 
promote check",
+                       obj: &unstructured.Unstructured{
+                               Object: map[string]any{
+                                       "apiVersion": 
kargoapi.GroupVersion.String(),
+                                       "kind":       "Stage",
+                                       "metadata":   map[string]any{"name": 
stage, "namespace": project},
+                               },
+                       },
+                       assert: func(t *testing.T, err error, called bool) {
+                               require.NoError(t, err)
+                               require.False(t, called, "authorizeFn must not 
be called for non-Promotions")
+                       },
+               },
+               {
+                       name: "Promotion without a target Stage is left to 
validation",
+                       obj: &unstructured.Unstructured{
+                               Object: map[string]any{
+                                       "apiVersion": 
kargoapi.GroupVersion.String(),
+                                       "kind":       "Promotion",
+                                       "metadata":   map[string]any{"name": 
"fake-promotion", "namespace": project},
+                                       "spec":       map[string]any{"freight": 
"fake-freight"},
+                               },
+                       },
+                       assert: func(t *testing.T, err error, called bool) {
+                               require.NoError(t, err)
+                               require.False(t, called, "authorizeFn must not 
be called without a target Stage")
+                       },
+               },
+               {
+                       name: "Promotion checks the promote verb on the target 
Stage",
+                       obj:  promotion(),
+                       authorizeFn: func(
+                               _ context.Context,
+                               verb string,
+                               gvr schema.GroupVersionResource,
+                               _ string,
+                               key types.NamespacedName,
+                       ) error {
+                               require.Equal(t, "promote", verb)
+                               require.Equal(t, 
kargoapi.GroupVersion.WithResource("stages"), gvr)
+                               require.Equal(t, project, key.Namespace)
+                               require.Equal(t, stage, key.Name)
+                               return nil
+                       },
+                       assert: func(t *testing.T, err error, called bool) {
+                               require.NoError(t, err)
+                               require.True(t, called)
+                       },
+               },
+               {
+                       name: "denied promote authorization is surfaced",
+                       obj:  promotion(),
+                       authorizeFn: func(
+                               context.Context,
+                               string,
+                               schema.GroupVersionResource,
+                               string,
+                               types.NamespacedName,
+                       ) error {
+                               return errors.New("not permitted to promote")
+                       },
+                       assert: func(t *testing.T, err error, called bool) {
+                               require.ErrorContains(t, err, "not permitted to 
promote")
+                               require.True(t, called)
+                       },
+               },
+       }
+       for _, testCase := range testCases {
+               t.Run(testCase.name, func(t *testing.T) {
+                       var called bool
+                       s := &server{
+                               authorizeFn: func(
+                                       ctx context.Context,
+                                       verb string,
+                                       gvr schema.GroupVersionResource,
+                                       subresource string,
+                                       key types.NamespacedName,
+                               ) error {
+                                       called = true
+                                       if testCase.authorizeFn != nil {
+                                               return 
testCase.authorizeFn(ctx, verb, gvr, subresource, key)
+                                       }
+                                       return nil
+                               },
+                       }
+                       err := s.authorizeResourceCreate(context.Background(), 
testCase.obj)
+                       testCase.assert(t, err, called)
+               })
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/create_resource_v1alpha1.go 
new/kargo-cli-1.10.9/pkg/server/create_resource_v1alpha1.go
--- old/kargo-cli-1.10.8/pkg/server/create_resource_v1alpha1.go 2026-06-26 
01:39:10.000000000 +0200
+++ new/kargo-cli-1.10.9/pkg/server/create_resource_v1alpha1.go 2026-07-15 
01:49:54.000000000 +0200
@@ -183,6 +183,16 @@
                }, errSecretManagementDisabled
        }
 
+       // Enforce authorization checks the authorizing client cannot perform
+       // implicitly (e.g. the "promote" verb required to create a Promotion).
+       if err := s.authorizeResourceCreate(ctx, obj); err != nil {
+               return createResourceResult{Error: err.Error()}, err
+       }
+       // Block RBAC privilege escalation via this generic path.
+       if err := s.verifyNoEscalation(ctx, obj); err != nil {
+               return createResourceResult{Error: err.Error()}, err
+       }
+
        // Note: We don't blindly attempt creating the resource because many 
resource
        // types have defaulting and/or validating webhooks and what we do not 
want is
        // for some error from a webhook to obscure the fact that the resource 
already
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/create_resource_v1alpha1_test.go 
new/kargo-cli-1.10.9/pkg/server/create_resource_v1alpha1_test.go
--- old/kargo-cli-1.10.8/pkg/server/create_resource_v1alpha1_test.go    
2026-06-26 01:39:10.000000000 +0200
+++ new/kargo-cli-1.10.9/pkg/server/create_resource_v1alpha1_test.go    
2026-07-15 01:49:54.000000000 +0200
@@ -2,13 +2,17 @@
 
 import (
        "bytes"
+       "context"
        "encoding/json"
+       "errors"
        "net/http"
        "net/http/httptest"
        "testing"
 
        "github.com/stretchr/testify/require"
+       apierrors "k8s.io/apimachinery/pkg/api/errors"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime/schema"
        "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
@@ -195,6 +199,85 @@
                                        require.NoError(t, err)
                                },
                        },
+                       {
+                               name: "denies Promotion creation without 
promote permission",
+                               serverSetup: func(_ *testing.T, s *server) {
+                                       s.authorizeFn = func(
+                                               _ context.Context,
+                                               _ string,
+                                               gvr schema.GroupVersionResource,
+                                               _ string,
+                                               key client.ObjectKey,
+                                       ) error {
+                                               return apierrors.NewForbidden(
+                                                       gvr.GroupResource(),
+                                                       key.Name,
+                                                       errors.New("not 
permitted to promote"),
+                                               )
+                                       }
+                               },
+                               body: mustJSONBody(&kargoapi.Promotion{
+                                       TypeMeta: metav1.TypeMeta{
+                                               APIVersion: 
kargoapi.GroupVersion.String(),
+                                               Kind:       "Promotion",
+                                       },
+                                       ObjectMeta: metav1.ObjectMeta{
+                                               Name:      "fake-promotion",
+                                               Namespace: testProject.Name,
+                                       },
+                                       Spec: kargoapi.PromotionSpec{
+                                               Stage:   "fake-stage",
+                                               Freight: "fake-freight",
+                                       },
+                               }),
+                               assertions: func(t *testing.T, w 
*httptest.ResponseRecorder, c client.Client) {
+                                       require.Equal(t, http.StatusForbidden, 
w.Code)
+                                       // The Promotion must not have been 
created.
+                                       err := c.Get(
+                                               t.Context(),
+                                               client.ObjectKey{Namespace: 
testProject.Name, Name: "fake-promotion"},
+                                               &kargoapi.Promotion{},
+                                       )
+                                       require.True(t, 
apierrors.IsNotFound(err))
+                               },
+                       },
+                       {
+                               name: "creates Promotion when promote is 
permitted",
+                               serverSetup: func(_ *testing.T, s *server) {
+                                       s.authorizeFn = func(
+                                               context.Context,
+                                               string,
+                                               schema.GroupVersionResource,
+                                               string,
+                                               client.ObjectKey,
+                                       ) error {
+                                               return nil
+                                       }
+                               },
+                               body: mustJSONBody(&kargoapi.Promotion{
+                                       TypeMeta: metav1.TypeMeta{
+                                               APIVersion: 
kargoapi.GroupVersion.String(),
+                                               Kind:       "Promotion",
+                                       },
+                                       ObjectMeta: metav1.ObjectMeta{
+                                               Name:      "fake-promotion",
+                                               Namespace: testProject.Name,
+                                       },
+                                       Spec: kargoapi.PromotionSpec{
+                                               Stage:   "fake-stage",
+                                               Freight: "fake-freight",
+                                       },
+                               }),
+                               assertions: func(t *testing.T, w 
*httptest.ResponseRecorder, c client.Client) {
+                                       require.Equal(t, http.StatusCreated, 
w.Code)
+                                       // The Promotion must have been created.
+                                       require.NoError(t, c.Get(
+                                               t.Context(),
+                                               client.ObjectKey{Namespace: 
testProject.Name, Name: "fake-promotion"},
+                                               &kargoapi.Promotion{},
+                                       ))
+                               },
+                       },
                },
        )
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/rbac/generic_escalation.go 
new/kargo-cli-1.10.9/pkg/server/rbac/generic_escalation.go
--- old/kargo-cli-1.10.8/pkg/server/rbac/generic_escalation.go  1970-01-01 
01:00:00.000000000 +0100
+++ new/kargo-cli-1.10.9/pkg/server/rbac/generic_escalation.go  2026-07-15 
01:49:54.000000000 +0200
@@ -0,0 +1,303 @@
+package rbac
+
+import (
+       "context"
+       "fmt"
+       "slices"
+
+       corev1 "k8s.io/api/core/v1"
+       rbacv1 "k8s.io/api/rbac/v1"
+       "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+       "k8s.io/apimachinery/pkg/runtime"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+
+       rbacapi "github.com/akuity/kargo/api/rbac/v1alpha1"
+       "github.com/akuity/kargo/pkg/server/kubernetes"
+)
+
+var (
+       roleGVK               = rbacv1.SchemeGroupVersion.WithKind("Role")
+       roleBindingGVK        = 
rbacv1.SchemeGroupVersion.WithKind("RoleBinding")
+       clusterRoleGVK        = 
rbacv1.SchemeGroupVersion.WithKind("ClusterRole")
+       clusterRoleBindingGVK = 
rbacv1.SchemeGroupVersion.WithKind("ClusterRoleBinding")
+       serviceAccountGVK     = 
corev1.SchemeGroupVersion.WithKind("ServiceAccount")
+)
+
+// VerifyResourceNotEscalating guards the generic resource endpoints against
+// RBAC privilege escalation. The authorizing client checks only that the
+// requester may perform the verb -- e.g. create Roles in the namespace -- not
+// that they already hold the permissions the resource would grant; a
+// SubjectAccessReview answers the former, never the latter. Kubernetes itself
+// enforces the latter at admission, but against the identity performing the
+// write -- and these endpoints write with the API server's own privileged
+// credentials, so that check passes vacuously. This supplies what neither 
does:
+// it requires the requester (bound to ctx) to already hold every permission 
the
+// created or updated obj would confer.
+//
+// Only these kinds confer permissions; any other returns nil:
+//
+//   - Role, ClusterRole: the rules they declare.
+//   - RoleBinding, ClusterRoleBinding: the rules of the Role or ClusterRole
+//     they reference.
+//   - ServiceAccount annotated for OIDC claim mapping: the rules of every Role
+//     and ClusterRole bound to it, because mapping a claim onto it hands those
+//     permissions to every user bearing that claim.
+//
+// Each permission is verified at the scope where it applies: namespaced kinds
+// in obj's namespace, cluster-scoped kinds cluster-wide. (A RoleBinding is
+// namespaced even when it references a ClusterRole.)
+//
+// authz runs the "does the requester already hold this?" checks; a nil authz
+// disables verification (tests, non-authorizing local mode). resolver reads 
the
+// Roles and bindings needed to decide and must always be able to read them,
+// e.g. the server's internal client rather than one scoped to the requester.
+// globalNamespaces matters only for the ServiceAccount case; see
+// verifyServiceAccountBindingsNotEscalating.
+func VerifyResourceNotEscalating(
+       ctx context.Context,
+       authz kubernetes.Authorizer,
+       resolver client.Client,
+       globalNamespaces []string,
+       obj *unstructured.Unstructured,
+) error {
+       if obj == nil || authz == nil {
+               return nil
+       }
+       switch obj.GroupVersionKind() {
+       case roleGVK:
+               role := &rbacv1.Role{}
+               if err := fromUnstructured(obj, role); err != nil {
+                       return err
+               }
+               _, err := verifyRulesNotEscalating(ctx, authz, 
obj.GetNamespace(), role.Rules)
+               return err
+       case clusterRoleGVK:
+               clusterRole := &rbacv1.ClusterRole{}
+               if err := fromUnstructured(obj, clusterRole); err != nil {
+                       return err
+               }
+               _, err := verifyRulesNotEscalating(
+                       ctx,
+                       authz,
+                       "", // Cluster-scoped; no namespace
+                       clusterRole.Rules,
+               )
+               return err
+       case roleBindingGVK:
+               rb := &rbacv1.RoleBinding{}
+               if err := fromUnstructured(obj, rb); err != nil {
+                       return err
+               }
+               // A namespaced RoleBinding confers within its own namespace, 
even when it
+               // references a ClusterRole.
+               rules, err := resolveRoleRefRules(ctx, resolver, 
obj.GetNamespace(), rb.RoleRef)
+               if err != nil {
+                       return err
+               }
+               _, err = verifyRulesNotEscalating(ctx, authz, 
obj.GetNamespace(), rules)
+               return err
+       case clusterRoleBindingGVK:
+               crb := &rbacv1.ClusterRoleBinding{}
+               if err := fromUnstructured(obj, crb); err != nil {
+                       return err
+               }
+               // A ClusterRoleBinding may only reference a ClusterRole, and 
confers it
+               // cluster-wide.
+               rules, err := resolveRoleRefRules(
+                       ctx,
+                       resolver,
+                       "", // Cluster-scoped; no namespace
+                       crb.RoleRef,
+               )
+               if err != nil {
+                       return err
+               }
+               _, err = verifyRulesNotEscalating(
+                       ctx,
+                       authz,
+                       "", // Cluster-scoped; no namespace
+                       rules,
+               )
+               return err
+       case serviceAccountGVK:
+               // Only ServiceAccounts that map identities via OIDC claim 
annotations
+               // can confer anything; without claims, no identity gains the 
SA's Roles.
+               claims, err := 
rbacapi.OIDCClaimsFromAnnotationValues(obj.GetAnnotations())
+               if err != nil {
+                       // Malformed claim annotations: fail closed rather than 
skip the check.
+                       return fmt.Errorf("error reading ServiceAccount claim 
annotations: %w", err)
+               }
+               if len(claims) == 0 {
+                       return nil
+               }
+               return verifyServiceAccountBindingsNotEscalating(
+                       ctx, authz, resolver, globalNamespaces, 
obj.GetNamespace(), obj.GetName(),
+               )
+       default:
+               return nil
+       }
+}
+
+// fromUnstructured decodes the raw obj into a typed target (Role, RoleBinding,
+// etc.) so the escalation check can inspect it.
+func fromUnstructured(obj *unstructured.Unstructured, target any) error {
+       if err := runtime.DefaultUnstructuredConverter.FromUnstructured(
+               obj.Object, target,
+       ); err != nil {
+               return fmt.Errorf(
+                       "error decoding %s for escalation check: %w", 
obj.GetKind(), err,
+               )
+       }
+       return nil
+}
+
+// verifyServiceAccountBindingsNotEscalating handles the ServiceAccount case: 
it
+// requires the requester to already hold every permission conferred on the
+// identities that claim mapping binds to this ServiceAccount.
+//
+// Those permissions come from the RoleBindings and ClusterRoleBindings naming
+// the ServiceAccount as a subject, found by listing (a binding may have any
+// name). Only bindings that grant usable authority matter, which turns on 
where
+// the authorizing client consults this ServiceAccount: its own namespace only
+// -- or every namespace, if that namespace is one of globalNamespaces. So:
+//
+//   - RoleBindings: listed in the ServiceAccount's own namespace (a binding
+//     elsewhere confers nothing the mapped identity could use), or in every
+//     namespace when that namespace is global. Each is verified where it
+//     confers -- its own namespace.
+//   - ClusterRoleBindings: always listed; verified cluster-wide.
+//
+// Fails closed on any read error.
+func verifyServiceAccountBindingsNotEscalating(
+       ctx context.Context,
+       authz kubernetes.Authorizer,
+       resolver client.Client,
+       globalNamespaces []string,
+       namespace string,
+       saName string,
+) error {
+       if resolver == nil {
+               return fmt.Errorf(
+                       "no client available to resolve bindings of 
ServiceAccount %q", saName,
+               )
+       }
+
+       // Own namespace only, unless it is global -- then every namespace.
+       roleBindings := &rbacv1.RoleBindingList{}
+       var listOpts []client.ListOption
+       if !slices.Contains(globalNamespaces, namespace) {
+               listOpts = append(listOpts, client.InNamespace(namespace))
+       }
+       if err := resolver.List(ctx, roleBindings, listOpts...); err != nil {
+               return fmt.Errorf(
+                       "error listing RoleBindings for escalation check: %w", 
err,
+               )
+       }
+       for _, rb := range roleBindings.Items {
+               if !subjectsReferenceServiceAccount(rb.Subjects, rb.Namespace, 
namespace, saName) {
+                       continue
+               }
+               rules, err := resolveRoleRefRules(ctx, resolver, rb.Namespace, 
rb.RoleRef)
+               if err != nil {
+                       return err
+               }
+               if _, err := verifyRulesNotEscalating(ctx, authz, rb.Namespace, 
rules); err != nil {
+                       return err
+               }
+       }
+
+       // ClusterRoleBindings -- conferred cluster-wide.
+       clusterRoleBindings := &rbacv1.ClusterRoleBindingList{}
+       if err := resolver.List(ctx, clusterRoleBindings); err != nil {
+               return fmt.Errorf(
+                       "error listing ClusterRoleBindings for escalation 
check: %w", err,
+               )
+       }
+       for _, crb := range clusterRoleBindings.Items {
+               // ClusterRoleBinding subjects have no default namespace, so 
pass "".
+               if !subjectsReferenceServiceAccount(crb.Subjects, "", 
namespace, saName) {
+                       continue
+               }
+               rules, err := resolveRoleRefRules(ctx, resolver, "", 
crb.RoleRef)
+               if err != nil {
+                       return err
+               }
+               if _, err := verifyRulesNotEscalating(ctx, authz, "", rules); 
err != nil {
+                       return err
+               }
+       }
+       return nil
+}
+
+// subjectsReferenceServiceAccount reports whether any subject names the
+// ServiceAccount identified by saNamespace and saName. A subject that omits 
its
+// namespace defaults to bindingNamespace: the binding's own namespace for a
+// RoleBinding, or "" for a ClusterRoleBinding, whose ServiceAccount subjects
+// must state a namespace explicitly.
+func subjectsReferenceServiceAccount(
+       subjects []rbacv1.Subject,
+       bindingNamespace string,
+       saNamespace string,
+       saName string,
+) bool {
+       for _, s := range subjects {
+               if s.Kind != "ServiceAccount" || s.Name != saName {
+                       continue
+               }
+               ns := s.Namespace
+               if ns == "" {
+                       ns = bindingNamespace
+               }
+               if ns == saNamespace {
+                       return true
+               }
+       }
+       return false
+}
+
+// resolveRoleRefRules returns the rules of the Role or ClusterRole named by
+// roleRef; namespace locates a Role (ClusterRoles are cluster-scoped). It 
fails
+// closed: any read error, including a missing Role, is returned so the check
+// cannot be bypassed by pointing at a Role it cannot resolve.
+func resolveRoleRefRules(
+       ctx context.Context,
+       resolver client.Client,
+       namespace string,
+       roleRef rbacv1.RoleRef,
+) ([]rbacv1.PolicyRule, error) {
+       if resolver == nil {
+               return nil, fmt.Errorf("no client available to resolve roleRef 
%q", roleRef.Name)
+       }
+       switch roleRef.Kind {
+       case "Role":
+               role := &rbacv1.Role{}
+               if err := resolver.Get(
+                       ctx,
+                       client.ObjectKey{Namespace: namespace, Name: 
roleRef.Name},
+                       role,
+               ); err != nil {
+                       return nil, fmt.Errorf(
+                               "error resolving referenced Role %q in 
namespace %q for escalation check: %w",
+                               roleRef.Name, namespace, err,
+                       )
+               }
+               return role.Rules, nil
+       case "ClusterRole":
+               clusterRole := &rbacv1.ClusterRole{}
+               if err := resolver.Get(
+                       ctx,
+                       client.ObjectKey{Name: roleRef.Name},
+                       clusterRole,
+               ); err != nil {
+                       return nil, fmt.Errorf(
+                               "error resolving referenced ClusterRole %q for 
escalation check: %w",
+                               roleRef.Name, err,
+                       )
+               }
+               return clusterRole.Rules, nil
+       default:
+               return nil, fmt.Errorf(
+                       "unsupported roleRef kind %q", roleRef.Kind,
+               )
+       }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/rbac/generic_escalation_test.go 
new/kargo-cli-1.10.9/pkg/server/rbac/generic_escalation_test.go
--- old/kargo-cli-1.10.8/pkg/server/rbac/generic_escalation_test.go     
1970-01-01 01:00:00.000000000 +0100
+++ new/kargo-cli-1.10.9/pkg/server/rbac/generic_escalation_test.go     
2026-07-15 01:49:54.000000000 +0200
@@ -0,0 +1,320 @@
+package rbac
+
+import (
+       "context"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+       corev1 "k8s.io/api/core/v1"
+       rbacv1 "k8s.io/api/rbac/v1"
+       apierrors "k8s.io/apimachinery/pkg/api/errors"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+       "k8s.io/apimachinery/pkg/runtime"
+       "k8s.io/apimachinery/pkg/runtime/schema"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+       rbacapi "github.com/akuity/kargo/api/rbac/v1alpha1"
+       "github.com/akuity/kargo/pkg/server/kubernetes"
+)
+
+func TestVerifyResourceNotEscalating(t *testing.T) {
+       const ns = "fake-project"
+       const otherNS = "other-project"
+       const globalNS = "kargo-global"
+
+       role := &rbacv1.Role{
+               TypeMeta: metav1.TypeMeta{
+                       APIVersion: "rbac.authorization.k8s.io/v1",
+                       Kind:       "Role",
+               },
+               ObjectMeta: metav1.ObjectMeta{Name: "powerful", Namespace: ns},
+               Rules: []rbacv1.PolicyRule{{
+                       APIGroups: []string{""},
+                       Resources: []string{"secrets"},
+                       Verbs:     []string{"get"},
+               }},
+       }
+       roleBinding := &rbacv1.RoleBinding{
+               TypeMeta: metav1.TypeMeta{
+                       APIVersion: "rbac.authorization.k8s.io/v1",
+                       Kind:       "RoleBinding",
+               },
+               ObjectMeta: metav1.ObjectMeta{Name: "binding", Namespace: ns},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "Role",
+                       Name:     "powerful",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"attacker", Namespace: ns}},
+       }
+
+       clusterRole := &rbacv1.ClusterRole{
+               TypeMeta: metav1.TypeMeta{
+                       APIVersion: "rbac.authorization.k8s.io/v1",
+                       Kind:       "ClusterRole",
+               },
+               ObjectMeta: metav1.ObjectMeta{Name: "cluster-powerful"},
+               Rules: []rbacv1.PolicyRule{{
+                       APIGroups: []string{""},
+                       Resources: []string{"secrets"},
+                       Verbs:     []string{"get"},
+               }},
+       }
+       clusterRoleBinding := &rbacv1.ClusterRoleBinding{
+               TypeMeta: metav1.TypeMeta{
+                       APIVersion: "rbac.authorization.k8s.io/v1",
+                       Kind:       "ClusterRoleBinding",
+               },
+               ObjectMeta: metav1.ObjectMeta{Name: "cluster-binding"},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "ClusterRole",
+                       Name:     "cluster-powerful",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"attacker", Namespace: ns}},
+       }
+
+       // A RoleBinding that binds "bound-sa" to the powerful Role, so the
+       // ServiceAccount-claims vector can be resolved.
+       saBinding := &rbacv1.RoleBinding{
+               ObjectMeta: metav1.ObjectMeta{Name: "bound-sa-binding", 
Namespace: ns},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "Role",
+                       Name:     "powerful",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"bound-sa", Namespace: ns}},
+       }
+       // A ClusterRoleBinding that binds "cluster-bound-sa" to the powerful
+       // ClusterRole, so the cluster-wide ServiceAccount-claims vector 
resolves.
+       saClusterBinding := &rbacv1.ClusterRoleBinding{
+               ObjectMeta: metav1.ObjectMeta{Name: "cluster-bound-sa-binding"},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "ClusterRole",
+                       Name:     "cluster-powerful",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"cluster-bound-sa", Namespace: ns}},
+       }
+       // A Role and a RoleBinding in a DIFFERENT namespace that empower a
+       // ServiceAccount living in `ns`. This confers permissions within 
otherNS.
+       roleOtherNS := &rbacv1.Role{
+               ObjectMeta: metav1.ObjectMeta{Name: "xns-role", Namespace: 
otherNS},
+               Rules: []rbacv1.PolicyRule{{
+                       APIGroups: []string{""},
+                       Resources: []string{"secrets"},
+                       Verbs:     []string{"get"},
+               }},
+       }
+       saCrossNSBinding := &rbacv1.RoleBinding{
+               ObjectMeta: metav1.ObjectMeta{Name: "xns-binding", Namespace: 
otherNS},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "Role",
+                       Name:     "xns-role",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"xns-bound-sa", Namespace: ns}},
+       }
+       // A cross-namespace RoleBinding empowering a ServiceAccount that lives 
in a
+       // GLOBAL namespace. Because a global-namespace SA is consulted for ops 
in
+       // every namespace, such a binding is relevant and must be checked.
+       globalCrossNSBinding := &rbacv1.RoleBinding{
+               ObjectMeta: metav1.ObjectMeta{Name: "global-xns-binding", 
Namespace: otherNS},
+               RoleRef: rbacv1.RoleRef{
+                       APIGroup: "rbac.authorization.k8s.io",
+                       Kind:     "Role",
+                       Name:     "xns-role",
+               },
+               Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: 
"global-sa", Namespace: globalNS}},
+       }
+       saWithClaimsInNS := func(name, namespace string) 
*unstructured.Unstructured {
+               return toUnstructured(t, &corev1.ServiceAccount{
+                       TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: 
"ServiceAccount"},
+                       ObjectMeta: metav1.ObjectMeta{
+                               Name:      name,
+                               Namespace: namespace,
+                               Annotations: map[string]string{
+                                       rbacapi.AnnotationKeyOIDCClaim("sub"): 
"[email protected]",
+                               },
+                       },
+               })
+       }
+
+       saWithClaims := func(name string) *unstructured.Unstructured {
+               return toUnstructured(t, &corev1.ServiceAccount{
+                       TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: 
"ServiceAccount"},
+                       ObjectMeta: metav1.ObjectMeta{
+                               Name:      name,
+                               Namespace: ns,
+                               Annotations: map[string]string{
+                                       rbacapi.AnnotationKeyOIDCClaim("sub"): 
"[email protected]",
+                               },
+                       },
+               })
+       }
+
+       // Resolver client holds the referenced Roles and the SA bindings so the
+       // binding and ServiceAccount checks can resolve rules.
+       testScheme := runtime.NewScheme()
+       require.NoError(t, rbacv1.AddToScheme(testScheme))
+       require.NoError(t, corev1.AddToScheme(testScheme))
+       resolver := fake.NewClientBuilder().
+               WithScheme(testScheme).
+               WithObjects(
+                       role, clusterRole, roleOtherNS,
+                       saBinding, saClusterBinding, saCrossNSBinding, 
globalCrossNSBinding,
+               ).
+               Build()
+
+       holdsSecretsGet := func(verb string, gvr schema.GroupVersionResource, _ 
string) bool {
+               return verb == "get" && gvr.Group == "" && gvr.Resource == 
"secrets"
+       }
+       holdsNothing := func(string, schema.GroupVersionResource, string) bool 
{ return false }
+
+       testCases := []struct {
+               name             string
+               authz            kubernetes.Authorizer
+               globalNamespaces []string
+               obj              *unstructured.Unstructured
+               wantErr          bool
+       }{
+               {
+                       name:  "nil authorizer disables the check",
+                       authz: nil,
+                       obj:   toUnstructured(t, role),
+               },
+               {
+                       name:  "non-RBAC resource is ignored",
+                       authz: &fakeAuthorizer{allow: holdsNothing},
+                       obj: toUnstructured(t, &corev1.ConfigMap{
+                               TypeMeta:   metav1.TypeMeta{APIVersion: "v1", 
Kind: "ConfigMap"},
+                               ObjectMeta: metav1.ObjectMeta{Name: "cm", 
Namespace: ns},
+                       }),
+               },
+               {
+                       name:    "Role granting rules the requester lacks is 
rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     toUnstructured(t, role),
+                       wantErr: true,
+               },
+               {
+                       name:  "Role granting rules the requester holds is 
allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   toUnstructured(t, role),
+               },
+               {
+                       name:    "RoleBinding to a Role the requester lacks is 
rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     toUnstructured(t, roleBinding),
+                       wantErr: true,
+               },
+               {
+                       name:  "RoleBinding to a Role the requester holds is 
allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   toUnstructured(t, roleBinding),
+               },
+               {
+                       name:    "ServiceAccount with claims bound to a Role 
the requester lacks is rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     saWithClaims("bound-sa"),
+                       wantErr: true,
+               },
+               {
+                       name:  "ServiceAccount with claims bound to a Role the 
requester holds is allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   saWithClaims("bound-sa"),
+               },
+               {
+                       name:  "ServiceAccount without claims is ignored",
+                       authz: &fakeAuthorizer{allow: holdsNothing},
+                       obj: toUnstructured(t, &corev1.ServiceAccount{
+                               TypeMeta:   metav1.TypeMeta{APIVersion: "v1", 
Kind: "ServiceAccount"},
+                               ObjectMeta: metav1.ObjectMeta{Name: "bound-sa", 
Namespace: ns},
+                       }),
+               },
+               {
+                       name:    "ClusterRole granting rules the requester 
lacks is rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     toUnstructured(t, clusterRole),
+                       wantErr: true,
+               },
+               {
+                       name:  "ClusterRole granting rules the requester holds 
is allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   toUnstructured(t, clusterRole),
+               },
+               {
+                       name:    "ClusterRoleBinding to a ClusterRole the 
requester lacks is rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     toUnstructured(t, clusterRoleBinding),
+                       wantErr: true,
+               },
+               {
+                       name:  "ClusterRoleBinding to a ClusterRole the 
requester holds is allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   toUnstructured(t, clusterRoleBinding),
+               },
+               {
+                       name:    "ServiceAccount with claims bound cluster-wide 
the requester lacks is rejected",
+                       authz:   &fakeAuthorizer{allow: holdsNothing},
+                       obj:     saWithClaims("cluster-bound-sa"),
+                       wantErr: true,
+               },
+               {
+                       name:  "ServiceAccount with claims bound cluster-wide 
the requester holds is allowed",
+                       authz: &fakeAuthorizer{allow: holdsSecretsGet},
+                       obj:   saWithClaims("cluster-bound-sa"),
+               },
+               {
+                       // A binding in another namespace grants a 
project-namespace SA
+                       // authority the mapped identity could never exercise 
through Kargo
+                       // (that SA is never consulted for ops in the other 
namespace), so it
+                       // must NOT be treated as escalation -- even though the 
requester
+                       // holds nothing.
+                       name:  "cross-namespace binding to a project-namespace 
SA is not considered",
+                       authz: &fakeAuthorizer{allow: holdsNothing},
+                       obj:   saWithClaims("xns-bound-sa"),
+               },
+               {
+                       // A SA living in a global namespace IS consulted for 
ops in every
+                       // namespace, so a cross-namespace binding empowering 
it is relevant
+                       // and must be checked.
+                       name:             "cross-namespace binding to a 
global-namespace SA is enforced",
+                       authz:            &fakeAuthorizer{allow: holdsNothing},
+                       globalNamespaces: []string{globalNS},
+                       obj:              saWithClaimsInNS("global-sa", 
globalNS),
+                       wantErr:          true,
+               },
+               {
+                       name:             "cross-namespace binding to a 
global-namespace SA the requester holds is allowed",
+                       authz:            &fakeAuthorizer{allow: 
holdsSecretsGet},
+                       globalNamespaces: []string{globalNS},
+                       obj:              saWithClaimsInNS("global-sa", 
globalNS),
+               },
+       }
+       for _, testCase := range testCases {
+               t.Run(testCase.name, func(t *testing.T) {
+                       err := VerifyResourceNotEscalating(
+                               context.Background(),
+                               testCase.authz,
+                               resolver,
+                               testCase.globalNamespaces,
+                               testCase.obj,
+                       )
+                       if testCase.wantErr {
+                               require.Error(t, err)
+                               require.True(t, apierrors.IsForbidden(err))
+                               return
+                       }
+                       require.NoError(t, err)
+               })
+       }
+}
+
+func toUnstructured(t *testing.T, obj any) *unstructured.Unstructured {
+       t.Helper()
+       m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+       require.NoError(t, err)
+       return &unstructured.Unstructured{Object: m}
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/update_resource_v1alpha1.go 
new/kargo-cli-1.10.9/pkg/server/update_resource_v1alpha1.go
--- old/kargo-cli-1.10.8/pkg/server/update_resource_v1alpha1.go 2026-06-26 
01:39:10.000000000 +0200
+++ new/kargo-cli-1.10.9/pkg/server/update_resource_v1alpha1.go 2026-07-15 
01:49:54.000000000 +0200
@@ -153,6 +153,13 @@
                }, errSecretManagementDisabled
        }
 
+       // Block RBAC privilege escalation via this generic path. Applies to 
both
+       // creating and replacing (e.g. rewriting a Role's rules), so it runs 
before
+       // the create/update decision below.
+       if err := s.verifyNoEscalation(ctx, obj); err != nil {
+               return createOrUpdateResourceResult{Error: err.Error()}, err
+       }
+
        // Note: It would be tempting to blindly attempt creating the resource 
and
        // then update it instead if it already exists, but many resource types 
have
        // defaulting and/or validating webhooks and what we do not want is for 
some
@@ -175,6 +182,11 @@
                                Error: "resource does not exist",
                        }, libhttp.ErrorStr("resource does not exist", 
http.StatusNotFound)
                }
+               // Enforce authorization checks the authorizing client cannot 
perform
+               // implicitly (e.g. the "promote" verb required to create a 
Promotion).
+               if err := s.authorizeResourceCreate(ctx, obj); err != nil {
+                       return createOrUpdateResourceResult{Error: 
err.Error()}, err
+               }
                // If the object is a Project, annotate it with information 
about the user
                // who created it.
                annotateProjectWithCreator(ctx, obj)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/kargo-cli-1.10.8/pkg/server/update_resource_v1alpha1_test.go 
new/kargo-cli-1.10.9/pkg/server/update_resource_v1alpha1_test.go
--- old/kargo-cli-1.10.8/pkg/server/update_resource_v1alpha1_test.go    
2026-06-26 01:39:10.000000000 +0200
+++ new/kargo-cli-1.10.9/pkg/server/update_resource_v1alpha1_test.go    
2026-07-15 01:49:54.000000000 +0200
@@ -2,13 +2,17 @@
 
 import (
        "bytes"
+       "context"
        "encoding/json"
+       "errors"
        "net/http"
        "net/http/httptest"
        "testing"
 
        "github.com/stretchr/testify/require"
+       apierrors "k8s.io/apimachinery/pkg/api/errors"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime/schema"
        "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
@@ -217,6 +221,49 @@
                                },
                        },
                        {
+                               name: "upsert denies Promotion creation without 
promote permission",
+                               url:  "/v1beta1/resources?upsert=true",
+                               serverSetup: func(_ *testing.T, s *server) {
+                                       s.authorizeFn = func(
+                                               _ context.Context,
+                                               _ string,
+                                               gvr schema.GroupVersionResource,
+                                               _ string,
+                                               key client.ObjectKey,
+                                       ) error {
+                                               return apierrors.NewForbidden(
+                                                       gvr.GroupResource(),
+                                                       key.Name,
+                                                       errors.New("not 
permitted to promote"),
+                                               )
+                                       }
+                               },
+                               body: mustJSONBody(&kargoapi.Promotion{
+                                       TypeMeta: metav1.TypeMeta{
+                                               APIVersion: 
kargoapi.GroupVersion.String(),
+                                               Kind:       "Promotion",
+                                       },
+                                       ObjectMeta: metav1.ObjectMeta{
+                                               Name:      "fake-promotion",
+                                               Namespace: testProject.Name,
+                                       },
+                                       Spec: kargoapi.PromotionSpec{
+                                               Stage:   "fake-stage",
+                                               Freight: "fake-freight",
+                                       },
+                               }),
+                               assertions: func(t *testing.T, w 
*httptest.ResponseRecorder, c client.Client) {
+                                       require.Equal(t, http.StatusForbidden, 
w.Code)
+                                       // The Promotion must not have been 
created.
+                                       err := c.Get(
+                                               t.Context(),
+                                               client.ObjectKey{Namespace: 
testProject.Name, Name: "fake-promotion"},
+                                               &kargoapi.Promotion{},
+                                       )
+                                       require.True(t, 
apierrors.IsNotFound(err))
+                               },
+                       },
+                       {
                                name: "upsert creates resources from YAML",
                                url:  "/v1beta1/resources?upsert=true",
                                body: mustYAMLBody(testProject, testWarehouse),

++++++ kargo-cli.obsinfo ++++++
--- /var/tmp/diff_new_pack.xC94eG/_old  2026-07-15 17:08:13.832645810 +0200
+++ /var/tmp/diff_new_pack.xC94eG/_new  2026-07-15 17:08:13.836645946 +0200
@@ -1,5 +1,5 @@
 name: kargo-cli
-version: 1.10.8
-mtime: 1782430750
-commit: 50d29c10153965114f903645c83eadbe7bf58d3f
+version: 1.10.9
+mtime: 1784072994
+commit: 61a0cb831366ffc96594863bc9b1d8acf899d3da
 

++++++ vendor.tar.gz ++++++
/work/SRC/openSUSE:Factory/kargo-cli/vendor.tar.gz 
/work/SRC/openSUSE:Factory/.kargo-cli.new.1991/vendor.tar.gz differ: char 13, 
line 1

Reply via email to