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
