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-01 16:51:20 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/kargo-cli (Old) and /work/SRC/openSUSE:Factory/.kargo-cli.new.11887 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "kargo-cli" Wed Jul 1 16:51:20 2026 rev:56 rq:1362883 version:1.10.8 Changes: -------- --- /work/SRC/openSUSE:Factory/kargo-cli/kargo-cli.changes 2026-06-13 18:50:36.281573634 +0200 +++ /work/SRC/openSUSE:Factory/.kargo-cli.new.11887/kargo-cli.changes 2026-07-01 16:52:09.761419867 +0200 @@ -1,0 +2,6 @@ +Wed Jul 01 07:07:44 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.10.8: + no CLI-related changes or dependency updates + +------------------------------------------------------------------- Old: ---- kargo-cli-1.10.7.obscpio New: ---- kargo-cli-1.10.8.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ kargo-cli.spec ++++++ --- /var/tmp/diff_new_pack.KAIf8x/_old 2026-07-01 16:52:16.165641711 +0200 +++ /var/tmp/diff_new_pack.KAIf8x/_new 2026-07-01 16:52:16.165641711 +0200 @@ -19,7 +19,7 @@ %define executable_name kargo Name: kargo-cli -Version: 1.10.7 +Version: 1.10.8 Release: 0 Summary: CLI for the Kubernetes Application lifecycle orchestration License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.KAIf8x/_old 2026-07-01 16:52:16.249644621 +0200 +++ /var/tmp/diff_new_pack.KAIf8x/_new 2026-07-01 16:52:16.293646145 +0200 @@ -1,9 +1,9 @@ <services> <service name="obs_scm" mode="manual"> - <param name="url">https://github.com/akuity/kargo</param> + <param name="url">https://github.com/akuity/kargo.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v1.10.7</param> + <param name="revision">refs/tags/v1.10.8</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.KAIf8x/_old 2026-07-01 16:52:16.541654736 +0200 +++ /var/tmp/diff_new_pack.KAIf8x/_new 2026-07-01 16:52:16.589656399 +0200 @@ -1,6 +1,8 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/akuity/kargo</param> - <param name="changesrevision">17077e406ed309fcc830d08776d9b9a47fe6e134</param></service></servicedata> + <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> (No newline at EOF) ++++++ kargo-cli-1.10.7.obscpio -> kargo-cli-1.10.8.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/docs/docs/80-release-notes/89-v1.10.0.md new/kargo-cli-1.10.8/docs/docs/80-release-notes/89-v1.10.0.md --- old/kargo-cli-1.10.7/docs/docs/80-release-notes/89-v1.10.0.md 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/docs/docs/80-release-notes/89-v1.10.0.md 2026-06-26 01:39:10.000000000 +0200 @@ -12,6 +12,13 @@ (`freightMetadata(freightName).keyName`) or map access syntax (`freightMetadata(freightName)['key-name']`) to access specific values +* **Promote Endpoint Response Shape** ([#6135](https://github.com/akuity/kargo/pull/6135)): The + `POST /v1beta1/projects/{project}/stages/{stage}/promotions` REST endpoint now returns a bare + `Promotion` object, as its OpenAPI spec has always documented. Prior to v1.10.0, the server + incorrectly wrapped the object as `{"promotion": {...}}`, so reality did not match the spec. The + server and CLI were corrected to match. As a result, a v1.10.0+ CLI is incompatible with a pre-v1.10.0 + server (and vice versa) for the `promote` command -- upgrade your CLI to match the server version. + ## ⚠️ New Deprecations {#new-deprecations} * **`git-push` Default Integration Policy Changing in v1.12.0**: The `git-push` step now supports diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/hack/cve-scan.sh new/kargo-cli-1.10.8/hack/cve-scan.sh --- old/kargo-cli-1.10.7/hack/cve-scan.sh 1970-01-01 01:00:00.000000000 +0100 +++ new/kargo-cli-1.10.8/hack/cve-scan.sh 2026-06-26 01:39:10.000000000 +0200 @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# Scan a container image for CVEs with Grype, applying Akuity's published VEX +# (Vulnerability Exploitability eXchange) statements so that findings already +# assessed as not-affected are suppressed. +# +# Report-only by default: findings are written to the job log and the GitHub +# step summary, and a warning annotation is emitted when Critical/High CVEs +# are present. Set FAIL_ON to a severity (e.g. "high") to make the scan fail +# the build instead. +# +# This is the canonical scan recipe used across Akuity image pipelines. +# +# Usage: cve-scan.sh <image-ref> [<vex-repo>] +# +# <image-ref> Image to scan, with an explicit grype scheme, e.g.: +# docker:us-docker.pkg.dev/akuity/akp/akuity-platform:1.2.3 +# (image in the local docker daemon, typical for PR builds) +# registry:us-docker.pkg.dev/akuity/akp/akuity-platform@sha256:... +# (image in a remote registry, typical for pushed images; +# prefer scanning by digest) +# <vex-repo> Repository path used to look up VEX statements at +# ${VEX_BASE_URL}/pkg/oci/<vex-repo>/vex.json. Defaults to the +# repository derived from <image-ref> (scheme/tag/digest +# stripped). A missing VEX document is not an error: VEX +# publishing may not yet cover this image. +# +# Environment: +# GRYPE_CMD Path to the grype binary (default: "grype" on PATH) +# FAIL_ON Severity threshold to fail on ("critical", "high", ...). +# Empty (default) = report only, never fail. +# VEX_BASE_URL Base URL of the VEX repository (default: https://vex.akuity.io) + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 <image-ref> [<vex-repo>]" >&2 + exit 2 +fi + +IMAGE_REF="$1" +GRYPE_CMD="${GRYPE_CMD:-grype}" +FAIL_ON="${FAIL_ON:-}" +VEX_BASE_URL="${VEX_BASE_URL:-https://vex.akuity.io}" + +# Derive the repository from the image ref: strip the grype scheme prefix, +# any digest, and any tag (a colon after the last slash). +repo="${IMAGE_REF#docker:}" +repo="${repo#registry:}" +repo="${repo#oci-archive:}" +repo="${repo#docker-archive:}" +repo="${repo%%@*}" +base="${repo##*/}" +tag="" +if [[ "$base" == *:* ]]; then + tag="${base##*:}" + repo="${repo%:*}" +fi +VEX_REPO="${2:-$repo}" + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +# grype matches a VEX product @id only against the image's repo digest +# (<repo>@sha256:...) -- never the bare repo or <repo>:<tag>. The published doc +# carries the logical repo @id (it can't know each patch's digest), so we must +# re-stamp it to the digest of the image we are about to scan before applying. +# We stamp to the SCANNED image's repo@digest (not VEX_REPO): the statements +# come from VEX_REPO's published doc, but must carry the scanned image's +# identity to match. The package-level suppression is driven by the version- +# pinned subcomponent purls, which we leave untouched. +resolve_stamp_ref() { + # Already digest-pinned: reuse the digest directly. + if [[ "$IMAGE_REF" == *@sha256:* ]]; then + printf '%s@%s\n' "$repo" "${IMAGE_REF##*@}" + return + fi + # Tag ref -> resolve the digest grype will compute for this image. + case "$IMAGE_REF" in + docker:*) + # Local daemon: first RepoDigest (empty for --load builds never + # pushed -> no digest to match, so VEX is skipped below). + docker inspect --format \ + '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' \ + "${IMAGE_REF#docker:}" 2>/dev/null || true + ;; + registry:*) + # Remote / job-local registry tag: ask for the digest via crane. + # ${CRANE_INSECURE:+--insecure} mirrors GRYPE_REGISTRY_INSECURE_USE_HTTP + # for the job-local registry:2 service. + if command -v crane >/dev/null 2>&1; then + local d + d="$(crane digest ${CRANE_INSECURE:+--insecure} \ + "${repo}:${tag:-latest}" 2>/dev/null || true)" + [[ -n "$d" ]] && printf '%s@%s\n' "$repo" "$d" + fi + ;; + esac + # Best-effort: empty stdout means "couldn't resolve a digest" (the caller + # then scans without VEX). Never return non-zero — under `set -e` a + # non-zero return here would kill the script before that graceful path. + return 0 +} + +# --- Fetch VEX statements (tolerate absence) -------------------------------- +vex_args=() +vex_url="${VEX_BASE_URL}/pkg/oci/${VEX_REPO}/vex.json" +if curl -fsSL --retry 3 --max-time 30 "$vex_url" -o "$workdir/vex.json" 2>/dev/null \ + && jq -e .statements "$workdir/vex.json" >/dev/null 2>&1; then + stamp_ref="$(resolve_stamp_ref)" + if [[ -n "$stamp_ref" ]]; then + # Re-stamp every statement's product @id to the scanned digest. + jq --arg ref "$stamp_ref" \ + '.statements[].products[]."@id" = $ref' \ + "$workdir/vex.json" > "$workdir/vex.stamped.json" + echo "Applying VEX from $vex_url (re-stamped to $stamp_ref)" + vex_args=(--vex "$workdir/vex.stamped.json") + else + echo "::notice::VEX found at ${vex_url} but ${IMAGE_REF} is not digest-pinned and no repo digest could be resolved; scanning without VEX. Scan a registry:<repo>@<digest> ref (or install crane) to enable suppression." + fi +else + echo "::notice::No VEX statements published for ${VEX_REPO} (${vex_url}); scanning without VEX." +fi + +# --- Scan -------------------------------------------------------------------- +fail_args=() +if [[ -n "$FAIL_ON" ]]; then + fail_args=(--fail-on "$FAIL_ON") +fi + +echo "Scanning ${IMAGE_REF} ..." +rc=0 +"$GRYPE_CMD" "$IMAGE_REF" \ + ${vex_args[0]+"${vex_args[@]}"} \ + ${fail_args[0]+"${fail_args[@]}"} \ + -o "table=$workdir/table.txt" \ + -o "json=$workdir/report.json" \ + || rc=$? + +cat "$workdir/table.txt" 2>/dev/null || true + +# Grype failed to produce a usable report (e.g. the image could not be fetched +# or scanned). This is a scan error, not a CVE finding — only the *assessment* +# (findings vs FAIL_ON) is report-only, so a scan that can't run always fails, +# loudly, regardless of FAIL_ON. (VEX-resolution failures are handled earlier by +# scanning without VEX; this is the grype-can't-scan case.) +if ! jq -e '.matches' "$workdir/report.json" >/dev/null 2>&1; then + echo "::error::CVE scan of ${IMAGE_REF} failed: grype did not produce a report (exit ${rc})" >&2 + exit "$((rc == 0 ? 1 : rc))" +fi + +# --- Report ------------------------------------------------------------------ +critical=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$workdir/report.json") +high=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$workdir/report.json") +total=$(jq '.matches | length' "$workdir/report.json") + +# Per-location breakdown of Critical/High findings. Grype records the file a +# vulnerable package was found in (a bundled binary, or "" for OS packages), +# which is the key signal for "vulnerable code not in execute path" triage: +# a CVE that lives only in a build/scan tool is a strong not_affected case. +by_location=$(jq -r ' + [.matches[] + | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High") + | {loc: (.artifact.locations[0].path // "(os/image metadata)"), sev: .vulnerability.severity}] + | group_by(.loc)[] + | "| `\(.[0].loc)` | \(map(select(.sev == "Critical")) | length) | \(map(select(.sev == "High")) | length) |" +' "$workdir/report.json") + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "## CVE scan: \`${IMAGE_REF}\`" + echo + echo "| Critical | High | Total |" + echo "|---|---|---|" + echo "| ${critical} | ${high} | ${total} |" + if [[ -n "$by_location" ]]; then + echo + echo "### Critical/High by location" + echo + echo "| Location | Critical | High |" + echo "|---|---|---|" + echo "$by_location" + fi + echo + echo '```' + head -n 300 "$workdir/table.txt" + echo '```' + } >>"$GITHUB_STEP_SUMMARY" +fi + +if [[ "$critical" -gt 0 || "$high" -gt 0 ]]; then + echo "::warning::CVE scan of ${IMAGE_REF}: ${critical} critical, ${high} high (see job summary)" +fi + +if [[ "$rc" -ne 0 ]]; then + echo "::error::CVE scan of ${IMAGE_REF} failed the '${FAIL_ON}' severity gate" >&2 +fi +exit "$rc" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/create_role_v1alpha1_test.go new/kargo-cli-1.10.8/pkg/server/create_role_v1alpha1_test.go --- old/kargo-cli-1.10.7/pkg/server/create_role_v1alpha1_test.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/create_role_v1alpha1_test.go 2026-06-26 01:39:10.000000000 +0200 @@ -43,6 +43,18 @@ Namespace: testKargoRole.Namespace, }, } + // testGroupLessRole mirrors how the UI submits rules: apiGroups is omitted, so + // the server must resolve the group from the resource type. + testGroupLessRole := rbacapi.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-less-role", + Namespace: testProject.Name, + }, + Rules: []rbacv1.PolicyRule{{ + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }}, + } testRESTEndpoint( t, nil, http.MethodPost, "/v1beta1/projects/"+testProject.Name+"/roles", @@ -119,6 +131,26 @@ require.NoError(t, err) }, }, + { + name: "resolves omitted apiGroups from the resource type", + body: mustJSONBody(testGroupLessRole), + clientBuilder: fake.NewClientBuilder().WithObjects(testProject), + assertions: func(t *testing.T, w *httptest.ResponseRecorder, c client.Client) { + require.Equal(t, http.StatusCreated, w.Code) + + // The UI omits apiGroups; the server must resolve "stages" to the + // Kargo API group rather than leaving it in the core ("") group. + role := &rbacv1.Role{} + require.NoError(t, c.Get( + t.Context(), + client.ObjectKey{Namespace: testProject.Name, Name: testGroupLessRole.Name}, + role, + )) + require.Len(t, role.Rules, 1) + require.Equal(t, []string{kargoapi.GroupVersion.Group}, role.Rules[0].APIGroups) + require.Equal(t, []string{"stages"}, role.Rules[0].Resources) + }, + }, }, ) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/kubernetes/client.go new/kargo-cli-1.10.8/pkg/server/kubernetes/client.go --- old/kargo-cli-1.10.7/pkg/server/kubernetes/client.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/kubernetes/client.go 2026-06-26 01:39:10.000000000 +0200 @@ -104,11 +104,17 @@ type AuthorizingClient struct{} -// The Client interface combines the familiar controller-runtime WithWatch -// interface with a helpful Authorized() method. -type Client interface { - libClient.WithWatch - +// Authorizer authorizes a single (verb, resource) operation for the user bound +// to the context. The API server's authorizing Kubernetes client satisfies this +// interface; it returns a forbidden error when the user is not permitted. +// +// This mirrors the subset of kubernetes.Client used to prevent privilege +// escalation when editing Kargo Roles: because the API server performs Role +// writes with its own (broadly privileged) ServiceAccount, Kubernetes' own +// escalation check runs against the server, not the user. We therefore verify -- +// as the user -- that the caller already holds every permission they are trying +// to grant. +type Authorizer interface { // Authorize attempts to authorize the user to perform the desired operation // on the specified resource. If the user is not authorized, an error is // returned. @@ -119,6 +125,14 @@ subresource string, key libClient.ObjectKey, ) error +} + +// The Client interface combines the familiar controller-runtime WithWatch +// interface with a helpful Authorized() method. +type Client interface { + libClient.WithWatch + + Authorizer // InternalClient returns the internal controller-runtime client used by this // client. This is useful for cases where the API server needs to bypass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/escalation.go new/kargo-cli-1.10.8/pkg/server/rbac/escalation.go --- old/kargo-cli-1.10.7/pkg/server/rbac/escalation.go 1970-01-01 01:00:00.000000000 +0100 +++ new/kargo-cli-1.10.8/pkg/server/rbac/escalation.go 2026-06-26 01:39:10.000000000 +0200 @@ -0,0 +1,131 @@ +package rbac + +import ( + "context" + "fmt" + "strings" + + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/akuity/kargo/pkg/server/kubernetes" +) + +// splitResourceType splits a resource type such as "freights/status" into its +// resource ("freights") and subresource ("status"). Resource types without a +// "/" have an empty subresource. +func splitResourceType(resourceType string) (resource, subresource string) { + resource, subresource, _ = strings.Cut(resourceType, "/") + return resource, subresource +} + +// verifyRulesNotEscalating returns an error unless the user bound to the context +// already holds every permission described by the provided rules in the given +// namespace. It is used when creating or replacing a Role's rules, when granting +// a permission to a Role, and (via verifyBindingNotEscalating) when binding a +// user to a Role. A nil authorizer (tests, or non-authorizing local mode) +// disables the check. As a convenience, it also returns the normalized rules to +// avoid the caller having to normalize them again for storage in the Role. +// +// The rules are normalized first, which reduces them to atomic permissions -- one +// API group, one resource, at most one resource name, with "*" verbs expanded -- +// so the authorization check is a flat loop over concrete permissions. +func verifyRulesNotEscalating( + ctx context.Context, + authz kubernetes.Authorizer, + namespace string, + rules []rbacv1.PolicyRule, +) ([]rbacv1.PolicyRule, error) { + normalized, err := NormalizePolicyRules( + rules, + &PolicyRuleNormalizationOptions{IncludeCustomVerbsInExpansion: true}, + ) + if err != nil { + return nil, err + } + if authz == nil { + return normalized, nil + } + + for _, rule := range normalized { + // Normalized rules carry exactly one API group and one resource. + resourceType := rule.Resources[0] + resource, subresource := splitResourceType(resourceType) + gvr := schema.GroupVersionResource{Group: rule.APIGroups[0], Resource: resource} + var name string + if len(rule.ResourceNames) > 0 { + name = rule.ResourceNames[0] + } + key := client.ObjectKey{Namespace: namespace, Name: name} + for _, verb := range rule.Verbs { + if err := authorizeGrant( + ctx, authz, verb, gvr, subresource, key, resourceType, + ); err != nil { + return nil, err + } + } + } + return normalized, nil +} + +// verifyBindingNotEscalating verifies that the requester already holds every +// permission conferred by the Roles bound to the given Kargo Role's +// ServiceAccount. It is used when binding a user's identity to a Kargo Role via +// claim annotations (GrantRoleToUsers): doing so maps that identity onto the +// ServiceAccount and grants it all of the Role's permissions, so a requester +// must not be able to confer more than they themselves hold. A nil authorizer +// disables the check. +// +// Only the namespaced Roles are considered: callers reach this after +// manageableResources, which rejects ServiceAccounts bound to ClusterRoles, so +// resources.ClusterRoles is empty here. +func verifyBindingNotEscalating( + ctx context.Context, + authz kubernetes.Authorizer, + namespace string, + resources Resources, +) error { + if authz == nil { + return nil + } + var rules []rbacv1.PolicyRule + for _, role := range resources.Roles { + rules = append(rules, role.Rules...) + } + _, err := verifyRulesNotEscalating(ctx, authz, namespace, rules) + return err +} + +// authorizeGrant checks a single granted permission against the user's own +// authority. A forbidden result is rewritten as an escalation error; any other +// error is propagated so the operation fails closed. +func authorizeGrant( + ctx context.Context, + authz kubernetes.Authorizer, + verb string, + gvr schema.GroupVersionResource, + subresource string, + key client.ObjectKey, + resourceType string, +) error { + err := authz.Authorize(ctx, verb, gvr, subresource, key) + if err == nil { + return nil + } + if apierrors.IsForbidden(err) { + return apierrors.NewForbidden( + gvr.GroupResource(), + key.Name, + fmt.Errorf( + "requester may not grant permissions it does not hold: verb %q on resource %q", + verb, resourceType, + ), + ) + } + return fmt.Errorf( + "error verifying permission to grant verb %q on resource %q: %w", + verb, resourceType, err, + ) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/escalation_test.go new/kargo-cli-1.10.8/pkg/server/rbac/escalation_test.go --- old/kargo-cli-1.10.7/pkg/server/rbac/escalation_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/kargo-cli-1.10.8/pkg/server/rbac/escalation_test.go 2026-06-26 01:39:10.000000000 +0200 @@ -0,0 +1,269 @@ +package rbac + +import ( + "context" + "errors" + "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" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + rbacapi "github.com/akuity/kargo/api/rbac/v1alpha1" + kargoapi "github.com/akuity/kargo/api/v1alpha1" +) + +// fakeAuthorizer is a test Authorizer. When allow is nil, everything is +// permitted; otherwise allow decides. Every call is recorded. +type fakeAuthorizer struct { + allow func(verb string, gvr schema.GroupVersionResource, subresource string) bool + calls []string +} + +func (f *fakeAuthorizer) Authorize( + _ context.Context, + verb string, + gvr schema.GroupVersionResource, + subresource string, + key client.ObjectKey, +) error { + res := gvr.Resource + if subresource != "" { + res += "/" + subresource + } + f.calls = append(f.calls, verb+" "+gvr.Group+"/"+res) + if f.allow == nil || f.allow(verb, gvr, subresource) { + return nil + } + return apierrors.NewForbidden(gvr.GroupResource(), key.Name, errors.New("not allowed")) +} + +// authorizingFakeClient is both a controller-runtime client and an Authorizer, +// so NewKubernetesRolesDatabase wires the escalation check to it. +type authorizingFakeClient struct { + client.Client + authz *fakeAuthorizer +} + +func (a *authorizingFakeClient) Authorize( + ctx context.Context, + verb string, + gvr schema.GroupVersionResource, + subresource string, + key client.ObjectKey, +) error { + return a.authz.Authorize(ctx, verb, gvr, subresource, key) +} + +func TestSplitResourceType(t *testing.T) { + r, s := splitResourceType("secrets") + require.Equal(t, "secrets", r) + require.Empty(t, s) + + r, s = splitResourceType("freights/status") + require.Equal(t, "freights", r) + require.Equal(t, "status", s) +} + +func TestVerifyRulesNotEscalating(t *testing.T) { + rules := []rbacv1.PolicyRule{ + { + APIGroups: []string{kargoapi.GroupVersion.Group}, + Resources: []string{"stages"}, + Verbs: []string{"get", "promote"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get"}, + }, + } + + t.Run("permitted when user holds every rule", func(t *testing.T) { + authz := &fakeAuthorizer{} + _, err := verifyRulesNotEscalating(t.Context(), authz, testProject, rules) + require.NoError(t, err) + require.ElementsMatch( + t, + []string{ + "get " + kargoapi.GroupVersion.Group + "/stages", + "promote " + kargoapi.GroupVersion.Group + "/stages", + "get /secrets", + }, + authz.calls, + ) + }) + + t.Run("forbidden when user lacks one rule", func(t *testing.T) { + authz := &fakeAuthorizer{ + allow: func(_ string, gvr schema.GroupVersionResource, _ string) bool { + return gvr.Resource != "secrets" // user has no secrets access + }, + } + _, err := verifyRulesNotEscalating(t.Context(), authz, testProject, rules) + require.Error(t, err) + require.True(t, apierrors.IsForbidden(err)) + require.ErrorContains(t, err, `resource "secrets"`) + }) +} + +func TestNewKubernetesRolesDatabaseWiresAuthorizer(t *testing.T) { + plain := fake.NewClientBuilder().WithScheme(scheme).Build() + + db, ok := NewKubernetesRolesDatabase(plain, plain, RolesDatabaseConfig{}).(*rolesDatabase) + require.True(t, ok) + require.Nil(t, db.authorizer, "a plain client must leave the authorizer nil") + + authorizing := &authorizingFakeClient{Client: plain, authz: &fakeAuthorizer{}} + db, ok = NewKubernetesRolesDatabase(authorizing, plain, RolesDatabaseConfig{}).(*rolesDatabase) + require.True(t, ok) + require.NotNil(t, db.authorizer, "a client implementing Authorizer must populate the authorizer") +} + +func TestVerifyBindingNotEscalating(t *testing.T) { + resources := Resources{ + Roles: []rbacv1.Role{{ + Rules: []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"configmaps", "secrets"}, Verbs: []string{"*"}}, + }, + }}, + } + + t.Run("nil authorizer disables the check", func(t *testing.T) { + require.NoError(t, verifyBindingNotEscalating(t.Context(), nil, testProject, resources)) + }) + + t.Run("no bound roles means nothing to escalate", func(t *testing.T) { + require.NoError(t, verifyBindingNotEscalating(t.Context(), &fakeAuthorizer{}, testProject, Resources{})) + }) + + t.Run("permitted when requester holds the role's permissions", func(t *testing.T) { + require.NoError(t, verifyBindingNotEscalating(t.Context(), &fakeAuthorizer{}, testProject, resources)) + }) + + t.Run("forbidden when requester lacks a permission in the bound role", func(t *testing.T) { + authz := &fakeAuthorizer{ + allow: func(_ string, gvr schema.GroupVersionResource, _ string) bool { + return gvr.Resource != "secrets" + }, + } + err := verifyBindingNotEscalating(t.Context(), authz, testProject, resources) + require.Error(t, err) + require.True(t, apierrors.IsForbidden(err)) + require.ErrorContains(t, err, `resource "secrets"`) + }) +} + +// TestGrantRoleToUsersBlocksEscalation is the end-to-end guard: a user who +// cannot read secrets must not be able to bind an identity onto a Role that can. +func TestGrantRoleToUsersBlocksEscalation(t *testing.T) { + secretsRole := []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"*"}, + }} + claims := []rbacapi.Claim{{Name: "email", Values: []string{"[email protected]"}}} + + newDB := func(authz *fakeAuthorizer) (RolesDatabase, client.Client) { + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + managedServiceAccount(nil), + managedRole(secretsRole), + managedRoleBinding(), + ).Build() + return NewKubernetesRolesDatabase( + &authorizingFakeClient{Client: c, authz: authz}, c, RolesDatabaseConfig{}, + ), c + } + + t.Run("blocked when requester lacks the role's permissions", func(t *testing.T) { + db, c := newDB(&fakeAuthorizer{ + allow: func(_ string, gvr schema.GroupVersionResource, _ string) bool { + return gvr.Resource != "secrets" + }, + }) + _, err := db.GrantRoleToUsers(t.Context(), testProject, testKargoRoleName, claims) + require.Error(t, err) + require.True(t, apierrors.IsForbidden(err)) + + // The claim must not have been written to the ServiceAccount. + sa := &corev1.ServiceAccount{} + require.NoError(t, c.Get(t.Context(), objKey, sa)) + got, err := rbacapi.OIDCClaimsFromAnnotationValues(sa.Annotations) + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("allowed when requester holds the role's permissions", func(t *testing.T) { + db, c := newDB(&fakeAuthorizer{}) // permits everything + _, err := db.GrantRoleToUsers(t.Context(), testProject, testKargoRoleName, claims) + require.NoError(t, err) + + sa := &corev1.ServiceAccount{} + require.NoError(t, c.Get(t.Context(), objKey, sa)) + got, err := rbacapi.OIDCClaimsFromAnnotationValues(sa.Annotations) + require.NoError(t, err) + require.Equal(t, []string{"[email protected]"}, got["email"]) + }) +} + +// TestGrantPermissionsToRoleBlocksEscalation is the end-to-end guard: a user who +// cannot read secrets must not be able to grant secrets access via a Role edit. +func TestGrantPermissionsToRoleBlocksEscalation(t *testing.T) { + newDB := func(authz *fakeAuthorizer) (RolesDatabase, client.Client) { + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + managedServiceAccount(nil), + ).Build() + db := NewKubernetesRolesDatabase( + &authorizingFakeClient{Client: c, authz: authz}, c, RolesDatabaseConfig{}, + ) + // A fake client has no discovery, so stub group resolution. + rdb, ok := db.(*rolesDatabase) + require.True(t, ok) + rdb.resolveGroup = fakeGroupResolver + return db, c + } + + t.Run("blocked when requester lacks secrets", func(t *testing.T) { + db, c := newDB(&fakeAuthorizer{ + allow: func(_ string, gvr schema.GroupVersionResource, _ string) bool { + return gvr.Resource != "secrets" + }, + }) + _, err := db.GrantPermissionsToRole( + t.Context(), testProject, testKargoRoleName, + &rbacapi.ResourceDetails{ResourceType: "secrets", Verbs: []string{"get", "list"}}, + ) + require.Error(t, err) + require.True(t, apierrors.IsForbidden(err)) + require.ErrorContains(t, err, "may not grant permissions it does not hold") + + // The Role must not have been created as a side effect. + err = c.Get(t.Context(), objKey, &rbacv1.Role{}) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("allowed when requester holds the permission", func(t *testing.T) { + db, c := newDB(&fakeAuthorizer{}) // permits everything + _, err := db.GrantPermissionsToRole( + t.Context(), testProject, testKargoRoleName, + &rbacapi.ResourceDetails{ResourceType: "secrets", Verbs: []string{"get", "list"}}, + ) + require.NoError(t, err) + + role := &rbacv1.Role{} + require.NoError(t, c.Get(t.Context(), objKey, role)) + require.Equal( + t, + []rbacv1.PolicyRule{{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }}, + role.Rules, + ) + }) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/policy_rules.go new/kargo-cli-1.10.8/pkg/server/rbac/policy_rules.go --- old/kargo-cli-1.10.7/pkg/server/rbac/policy_rules.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/rbac/policy_rules.go 2026-06-26 01:39:10.000000000 +0200 @@ -7,9 +7,8 @@ rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - - rolloutsapi "github.com/akuity/kargo/api/stubs/rollouts/v1alpha1" - kargoapi "github.com/akuity/kargo/api/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" ) var ( @@ -77,23 +76,32 @@ ) (map[string]rbacv1.PolicyRule, error) { rulesMap := make(map[string]rbacv1.PolicyRule) for _, rule := range rules { + // Preserve the group(s) the rule specifies rather than overwriting them; + // an absent group means the core ("") group. (Previously the group was + // replaced using a hardcoded table, which could not account for resource + // types from Kargo's enterprise APIs or other CRDs. Callers that start from + // a group-less request resolve the group up front -- see groupResolver.) + groups := rule.APIGroups + if len(groups) == 0 { + groups = []string{""} + } + names := rule.ResourceNames + if len(names) == 0 { + names = []string{""} + } for _, resource := range rule.Resources { if err := validateResourceTypeName(resource); err != nil { return nil, err } - // We ignore the group in the rule and use what we know to be the correct - // group for the resource type. - group := getGroupName(resource) - if len(rule.ResourceNames) == 0 { - rule.ResourceNames = append(rule.ResourceNames, "") - } - for _, resourceName := range rule.ResourceNames { - verbs := rule.Verbs - key := RuleKey(group, resource, resourceName) - if existingRule, ok := rulesMap[key]; ok { - verbs = append(existingRule.Verbs, verbs...) + for _, group := range groups { + for _, resourceName := range names { + verbs := rule.Verbs + key := RuleKey(group, resource, resourceName) + if existingRule, ok := rulesMap[key]; ok { + verbs = append(existingRule.Verbs, verbs...) + } + rulesMap[key] = buildRule(group, resource, resourceName, verbs, opts) } - rulesMap[key] = buildRule(group, resource, resourceName, verbs, opts) } } } @@ -177,43 +185,113 @@ return rule } -// nolint: goconst +// validateResourceTypeName performs a best-effort check that resource is the plural form of a +// resource type. It deliberately does not validate against a fixed set of known types because +// otherwise we have to maintain a hardcoded table of all known resource types. Kubernetes resource +// names are conventionally the lowercase plural of a Kind and therefore end in "s"; an input that +// does not is almost certainly a singular fat-finger (e.g. "stage" instead of "stages"), so we +// reject it with a suggestion rather than silently creating a rule that matches nothing. func validateResourceTypeName(resource string) error { - switch resource { - case "analysisruns", "analysistemplates", "configmaps", "events", "freights", - "freights/status", "projectconfigs", "promotions", "rolebindings", "roles", - "secrets", "serviceaccounts", "stages", "warehouses", "promotiontasks": + // Subresources (e.g. "freights/status") are validated on their resource part. + base, subresource, hasSubresource := strings.Cut(resource, "/") + if base == "" { + return apierrors.NewBadRequest(fmt.Sprintf("unrecognized resource type %q", resource)) + } + if strings.HasSuffix(base, "s") { return nil - case "analysisrun", "analysistemplate", "configmap", "event", "freight", - "projectconfig", "promotion", "role", "rolebinding", "secret", - "serviceaccount", "stage", "warehouse": - return apierrors.NewBadRequest( - fmt.Sprintf(`unrecognized resource type %q; did you mean "%ss"?`, resource, resource), - ) - case "freight/status": - return apierrors.NewBadRequest( - `unrecognized resource type "freight/status"; did you mean "freights/status"?`, - ) - default: - return apierrors.NewBadRequest(fmt.Sprintf(`unrecognized resource type %q`, resource)) } + // Looks singular; suggest the plural form using Kubernetes' own guess. + plural, _ := meta.UnsafeGuessKindToResource(schema.GroupVersionKind{Kind: base}) + suggestion := plural.Resource + if hasSubresource { + suggestion += "/" + subresource + } + return apierrors.NewBadRequest(fmt.Sprintf( + "unrecognized resource type %q; did you mean %q?", resource, suggestion, + )) } -// nolint: goconst -func getGroupName(resourceType string) string { - // resourceType must already be validated - switch resourceType { - case "events", "secrets", "serviceaccounts": - return "" - case "rolebindings", "roles": - return rbacv1.SchemeGroupVersion.Group - case "freights", "freights/status", "promotions", "stages", "warehouses": - return kargoapi.GroupVersion.Group - case "analysisruns", "analysistemplates": - return rolloutsapi.GroupVersion.Group - default: - return "" // If the resourceType was validated, this will never happen +// groupResolver resolves the API group that serves a (plural) resource type. It +// is consulted only when a request carries no explicit group -- the Grant/Revoke +// ResourceDetails flow, and group-less rules submitted via Create/Update (see +// resolveRuleGroups). +type groupResolver func(resource string) (group string, err error) + +// newRESTMapperGroupResolver returns a groupResolver backed by a RESTMapper. The +// API server's RESTMapper is discovery-backed, so this resolves the group for +// any resource type the cluster actually serves -- core, CRDs, and Kargo's +// enterprise APIs alike -- without a hardcoded table or configuration. Known +// resources are served from the mapper's in-memory cache; it returns a +// bad-request error for an unknown or ambiguous resource type. +func newRESTMapperGroupResolver(mapper meta.RESTMapper) groupResolver { + return func(resource string) (string, error) { + // The group is a property of the resource, not the subresource. + res, _, _ := strings.Cut(resource, "/") + gvrs, err := mapper.ResourcesFor(schema.GroupVersionResource{Resource: res}) + if err != nil { + if meta.IsNoMatchError(err) { + return "", apierrors.NewBadRequest(fmt.Sprintf("unrecognized resource type %q", resource)) + } + return "", fmt.Errorf("error resolving API group for resource type %q: %w", resource, err) + } + groups := make(map[string]struct{}, len(gvrs)) + for _, gvr := range gvrs { + groups[gvr.Group] = struct{}{} + } + switch len(groups) { + case 1: + var group string + // NOTE(thomastaylor312): I know this looks weird, but it's the only way to get the + // single key out of a map in Go. + for g := range groups { + group = g + } + return group, nil + case 0: + return "", apierrors.NewBadRequest(fmt.Sprintf("unrecognized resource type %q", resource)) + default: + return "", apierrors.NewBadRequest(fmt.Sprintf( + "ambiguous resource type %q; it is served by multiple API groups", resource, + )) + } + } +} + +// resolveRuleGroups fills in the API group of any rule that does not specify +// one, by resolving it from the rule's resource type(s). Rules that already +// specify a group are returned unchanged. A group-less rule that lists multiple +// resources is split so each resource is paired with its own resolved group, +// since different resources may live in different groups. A nil resolver leaves +// rules unchanged. +// +// This is the entry point for group-less rules submitted through Create/Update +// (notably the UI, which omits apiGroups and relies on the server to resolve +// them); NormalizePolicyRules itself only preserves groups, never resolves them. +func resolveRuleGroups( + rules []rbacv1.PolicyRule, + resolve groupResolver, +) ([]rbacv1.PolicyRule, error) { + if resolve == nil { + return rules, nil + } + resolved := make([]rbacv1.PolicyRule, 0, len(rules)) + for _, rule := range rules { + if len(rule.APIGroups) > 0 || len(rule.Resources) == 0 { + resolved = append(resolved, rule) + continue + } + for _, resource := range rule.Resources { + group, err := resolve(resource) + if err != nil { + return nil, err + } + r := *rule.DeepCopy() + r.APIGroups = []string{group} + r.Resources = []string{resource} + resolved = append(resolved, r) + } } + return resolved, nil } func allVerbsFor(resourceType string, includeCustom bool) []string { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/policy_rules_test.go new/kargo-cli-1.10.8/pkg/server/rbac/policy_rules_test.go --- old/kargo-cli-1.10.7/pkg/server/rbac/policy_rules_test.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/rbac/policy_rules_test.go 2026-06-26 01:39:10.000000000 +0200 @@ -1,6 +1,7 @@ package rbac import ( + "errors" "testing" "github.com/stretchr/testify/require" @@ -163,10 +164,10 @@ ) }) - t.Run("correct groups are determined automatically", func(t *testing.T) { + t.Run("groups are preserved", func(t *testing.T) { rules, err := NormalizePolicyRules( []rbacv1.PolicyRule{{ - APIGroups: []string{"", "foo", "bar"}, + APIGroups: []string{kargoapi.GroupVersion.Group}, Resources: []string{"stages"}, Verbs: []string{"get"}, }}, @@ -175,10 +176,36 @@ require.NoError(t, err) require.Equal( t, + []rbacv1.PolicyRule{{ + APIGroups: []string{kargoapi.GroupVersion.Group}, + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }}, + rules, + ) + }) + + t.Run("multiple groups expand", func(t *testing.T) { + rules, err := NormalizePolicyRules( + []rbacv1.PolicyRule{{ + APIGroups: []string{"foo", "bar"}, + Resources: []string{"widgets"}, + Verbs: []string{"get"}, + }}, + nil, + ) + require.NoError(t, err) + require.Equal( + t, []rbacv1.PolicyRule{ { - APIGroups: []string{kargoapi.GroupVersion.Group}, - Resources: []string{"stages"}, + APIGroups: []string{"bar"}, + Resources: []string{"widgets"}, + Verbs: []string{"get"}, + }, + { + APIGroups: []string{"foo"}, + Resources: []string{"widgets"}, Verbs: []string{"get"}, }, }, @@ -189,16 +216,21 @@ t.Run("kitchen sink", func(t *testing.T) { rules, err := NormalizePolicyRules( []rbacv1.PolicyRule{ - { // Never mind that this doesn't make sense. It should all get fixed + { // group "" is preserved as core (not inferred); splits per resource APIGroups: []string{""}, Resources: []string{"serviceaccounts", "stages"}, Verbs: []string{"*"}, }, - { // These should get de-duped + { // merges with the get-only kargo stages rule below APIGroups: []string{kargoapi.GroupVersion.Group}, Resources: []string{"stages"}, Verbs: []string{"*"}, }, + { // merged with the stages rule above (same group + resource) + APIGroups: []string{kargoapi.GroupVersion.Group}, + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }, { APIGroups: []string{kargoapi.GroupVersion.Group}, Resources: []string{"warehouses"}, @@ -218,6 +250,16 @@ Verbs: allVerbs, }, { + // The group "" stages rule is preserved as core -- it is NOT + // inferred to kargo.akuity.io and so does NOT merge with the + // kargo.akuity.io stages rule below. (Group inference for + // group-less input happens at the Create/Update layer via + // resolveRuleGroups, not in NormalizePolicyRules.) + APIGroups: []string{""}, + Resources: []string{"stages"}, + Verbs: allStagesVerbs, + }, + { APIGroups: []string{kargoapi.GroupVersion.Group}, Resources: []string{"stages"}, Verbs: allStagesVerbs, @@ -238,5 +280,80 @@ rules, ) }) +} + +func TestResolveRuleGroups(t *testing.T) { + resolve := func(resource string) (string, error) { + switch resource { + case "stages": + return kargoapi.GroupVersion.Group, nil + case "secrets": + return "", nil + default: + return "", errors.New("unrecognized resource type") + } + } + + t.Run("nil resolver leaves rules unchanged", func(t *testing.T) { + in := []rbacv1.PolicyRule{{Resources: []string{"stages"}, Verbs: []string{"get"}}} + out, err := resolveRuleGroups(in, nil) + require.NoError(t, err) + require.Equal(t, in, out) + }) + + t.Run("explicit group is preserved", func(t *testing.T) { + in := []rbacv1.PolicyRule{{ + APIGroups: []string{"already.set"}, + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }} + out, err := resolveRuleGroups(in, resolve) + require.NoError(t, err) + require.Equal(t, in, out) + }) + + t.Run("empty group is resolved from the resource", func(t *testing.T) { + out, err := resolveRuleGroups([]rbacv1.PolicyRule{{ + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }}, resolve) + require.NoError(t, err) + require.Equal(t, []rbacv1.PolicyRule{{ + APIGroups: []string{kargoapi.GroupVersion.Group}, + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }}, out) + }) + + t.Run("group-less multi-resource rule splits per resource", func(t *testing.T) { + out, err := resolveRuleGroups([]rbacv1.PolicyRule{{ + Resources: []string{"stages", "secrets"}, + Verbs: []string{"get"}, + }}, resolve) + require.NoError(t, err) + require.Equal( + t, + []rbacv1.PolicyRule{ + { + APIGroups: []string{kargoapi.GroupVersion.Group}, + Resources: []string{"stages"}, + Verbs: []string{"get"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get"}, + }, + }, + out, + ) + }) + t.Run("resolver error is propagated", func(t *testing.T) { + _, err := resolveRuleGroups([]rbacv1.PolicyRule{{ + Resources: []string{"unknown"}, + Verbs: []string{"get"}, + }}, resolve) + require.Error(t, err) + }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/roles.go new/kargo-cli-1.10.8/pkg/server/rbac/roles.go --- old/kargo-cli-1.10.7/pkg/server/rbac/roles.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/rbac/roles.go 2026-06-26 01:39:10.000000000 +0200 @@ -17,6 +17,7 @@ rbacapi "github.com/akuity/kargo/api/rbac/v1alpha1" kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/pkg/server/kubernetes" ) type RolesDatabaseConfig struct { @@ -163,7 +164,15 @@ type rolesDatabase struct { client client.Client internalClient client.Client - cfg RolesDatabaseConfig + // NOTE(thomastaylor312): this just ends up being the same client as `client`, but we have it as + // a separate field so testing is easier because we can nil this out or mock what we need + authorizer kubernetes.Authorizer + // resolveGroup resolves the API group for a (plural) resource type when a + // request does not specify one (the Grant/Revoke ResourceDetails flow). It is + // derived from the client's discovery-backed RESTMapper in the constructor; + // tests may override it. + resolveGroup groupResolver + cfg RolesDatabaseConfig } // NewKubernetesRolesDatabase returns an implementation of the RolesDatabase @@ -175,11 +184,27 @@ internalClient client.Client, cfg RolesDatabaseConfig, ) RolesDatabase { - return &rolesDatabase{ + db := &rolesDatabase{ client: c, internalClient: internalClient, cfg: cfg, } + // The API server passes its authorizing client here. We use it to verify -- + // as the requesting user -- that a Role edit does not grant permissions the + // user does not already hold. This is necessary because Role writes are + // performed with the API server's own broadly-privileged ServiceAccount, so + // Kubernetes' built-in escalation prevention runs against the server rather + // than the user. Clients that do not implement Authorizer (test fakes, or the + // non-authorizing local-mode client whose writes are already constrained by + // the user's own credentials) leave this nil, disabling the check. + if authz, ok := c.(kubernetes.Authorizer); ok { + db.authorizer = authz + } + // Resolve API groups for group-less requests via the client's discovery-backed + // RESTMapper, so any served resource type (core, CRDs, enterprise APIs) is + // covered without a hardcoded table. + db.resolveGroup = newRESTMapperGroupResolver(c.RESTMapper()) + return db } // CreateRole implements the RolesDatabase interface. @@ -243,11 +268,29 @@ // If we get to here, we may proceed with creating the // ServiceAccount/Role/RoleBinding trio - sa, role, rb, err := RoleToResources(kargoRole) + // Fill in the API group of any rule that omits it (the UI sends group-less + // rules and relies on the server to resolve them from the resource type). + resolvedRules, err := resolveRuleGroups(kargoRole.Rules, c.resolveGroup) + if err != nil { + return nil, err + } + kargoRole.Rules = resolvedRules + + // NOTE(thomastaylor312): This normalizes the rules and then the verify step does it again. This + // is probably fine for now, but calling it out here in case there is a problem in the future + sa, role, rb, err = RoleToResources(kargoRole) if err != nil { return nil, fmt.Errorf("error converting Kargo Role to resources: %w", err) } + // Reject the create unless the requester already holds every permission in + // the new Role's rule set (prevents privilege escalation via Role creation). + if _, err = verifyRulesNotEscalating( + ctx, c.authorizer, kargoRole.Namespace, role.Rules, + ); err != nil { + return nil, err + } + // Append the description annotation to the Role if it exists if description, ok := kargoRole.Annotations[kargoapi.AnnotationKeyDescription]; ok { if sa.Annotations == nil { @@ -468,17 +511,9 @@ return nil, err } - group := getGroupName(resourceDetails.ResourceType) - - // Deal with wildcard verb - for _, verb := range resourceDetails.Verbs { - if strings.TrimSpace(verb) == "*" { - resourceDetails.Verbs = append( - resourceDetails.Verbs, - allVerbsFor(resourceDetails.ResourceType, true)..., - ) - break - } + group, err := c.resolveGroup(resourceDetails.ResourceType) + if err != nil { + return nil, err } newRole := role @@ -493,7 +528,22 @@ if resourceDetails.ResourceName != "" { newRule.ResourceNames = []string{resourceDetails.ResourceName} } - if newRole.Rules, err = NormalizePolicyRules(append(newRole.Rules, newRule), nil); err != nil { + + // Reject the grant unless the requester already holds every permission they + // are attempting to confer (prevents privilege escalation via Role editing). + // Only the new rule is checked; pre-existing rules were vetted when added. + // The normalized form is discarded here because storage needs the new rule + // merged with the role's existing rules, not the delta on its own. + if _, err = verifyRulesNotEscalating( + ctx, c.authorizer, project, []rbacv1.PolicyRule{newRule}, + ); err != nil { + return nil, err + } + + if newRole.Rules, err = NormalizePolicyRules( + append(newRole.Rules, newRule), + &PolicyRuleNormalizationOptions{IncludeCustomVerbsInExpansion: true}, + ); err != nil { return nil, fmt.Errorf("error normalizing RBAC policy rules: %w", err) } @@ -533,6 +583,13 @@ if _, _, err = manageableResources(resources); err != nil { return nil, err } + // Binding a user to a Kargo Role maps their identity onto its ServiceAccount, + // conferring the Role's permissions. Reject unless the requester already holds + // those permissions (prevents privilege escalation by binding a user -- + // possibly the requester themselves -- to a more powerful Role). + if err = verifyBindingNotEscalating(ctx, c.authorizer, project, resources); err != nil { + return nil, err + } if err = amendClaimAnnotations( resources.ServiceAccount, claimListToMap(claims), @@ -581,7 +638,6 @@ namespace, saList.Items[i].Name, ) - if err != nil { return nil, fmt.Errorf( "error getting underlying resources for Kargo Role %q from namespace %q: %w", @@ -683,7 +739,10 @@ return nil, err } - group := getGroupName(resourceDetails.ResourceType) + group, err := c.resolveGroup(resourceDetails.ResourceType) + if err != nil { + return nil, err + } filteredRules := make([]rbacv1.PolicyRule, 0, len(role.Rules)) for _, rule := range role.Rules { @@ -787,23 +846,34 @@ ) } + newRole := role + if newRole == nil { + newRole = buildNewRole(kargoRole.Namespace, kargoRole.Name) + } + // Fill in the API group of any rule that omits it (the UI sends group-less + // rules and relies on the server to resolve them from the resource type). + resolvedRules, err := resolveRuleGroups(kargoRole.Rules, c.resolveGroup) + if err != nil { + return nil, err + } + // Reject the update unless the requester already holds every permission in + // the resulting rule set (prevents privilege escalation via Role editing). + // The check runs before anything is persisted, so a denied update has no + // side effects. + normalized, err := verifyRulesNotEscalating( + ctx, c.authorizer, kargoRole.Namespace, resolvedRules, + ) + if err != nil { + return nil, err + } + newRole.Rules = normalized + if err = c.client.Update(ctx, resources.ServiceAccount); err != nil { return nil, fmt.Errorf( "error updating ServiceAccount %q in namespace %q: %w", kargoRole.Name, kargoRole.Namespace, err, ) } - - newRole := role - if newRole == nil { - newRole = buildNewRole(kargoRole.Namespace, kargoRole.Name) - } - if newRole.Rules, err = NormalizePolicyRules( - kargoRole.Rules, - &PolicyRuleNormalizationOptions{IncludeCustomVerbsInExpansion: true}, - ); err != nil { - return nil, fmt.Errorf("error normalizing RBAC policy rules: %w", err) - } if role == nil { if err := c.client.Create(ctx, newRole); err != nil { return nil, fmt.Errorf("error creating Role %q in namespace %q: %w", kargoRole.Name, kargoRole.Namespace, err) @@ -860,7 +930,8 @@ } for name, values := range claims { - kargoRole.Claims = append(kargoRole.Claims, + kargoRole.Claims = append( + kargoRole.Claims, rbacapi.Claim{ Name: name, Values: values, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rbac/roles_test.go new/kargo-cli-1.10.8/pkg/server/rbac/roles_test.go --- old/kargo-cli-1.10.7/pkg/server/rbac/roles_test.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/rbac/roles_test.go 2026-06-26 01:39:10.000000000 +0200 @@ -2,7 +2,9 @@ import ( "context" + "fmt" "maps" + "strings" "testing" "time" @@ -30,6 +32,23 @@ objKey = client.ObjectKey{Namespace: testProject, Name: testKargoRoleName} ) +// fakeGroupResolver stands in for the discovery-backed group resolver (which a +// fake client lacks), mapping the resource types used in tests to their API +// groups. It mirrors the mapping the old getGroupName table provided. +func fakeGroupResolver(resource string) (string, error) { + base, _, _ := strings.Cut(resource, "/") + switch base { + case "secrets", "serviceaccounts", "configmaps", "events": + return "", nil + case "roles", "rolebindings": + return rbacv1.SchemeGroupVersion.Group, nil + case "stages", "warehouses", "promotions", "freights", "projectconfigs", "promotiontasks": + return kargoapi.GroupVersion.Group, nil + default: + return "", apierrors.NewBadRequest(fmt.Sprintf("unrecognized resource type %q", resource)) + } +} + func init() { scheme = runtime.NewScheme() err := corev1.AddToScheme(scheme) @@ -512,6 +531,9 @@ managedServiceAccount(nil), ).Build() db := NewKubernetesRolesDatabase(c, c, RolesDatabaseConfig{}) + rdb, ok := db.(*rolesDatabase) + require.True(t, ok) + rdb.resolveGroup = fakeGroupResolver kargoRole, err := db.GrantPermissionsToRole( t.Context(), testProject, @@ -571,6 +593,9 @@ managedRoleBinding(), ).Build() db := NewKubernetesRolesDatabase(c, c, RolesDatabaseConfig{}) + rdb, ok := db.(*rolesDatabase) + require.True(t, ok) + rdb.resolveGroup = fakeGroupResolver kargoRole, err := db.GrantPermissionsToRole( t.Context(), testProject, @@ -929,6 +954,9 @@ managedRoleBinding(), ).Build() db := NewKubernetesRolesDatabase(c, c, RolesDatabaseConfig{}) + rdb, ok := db.(*rolesDatabase) + require.True(t, ok) + rdb.resolveGroup = fakeGroupResolver kargoRole, err := db.RevokePermissionsFromRole( t.Context(), testProject, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/pkg/server/rest_test.go new/kargo-cli-1.10.8/pkg/server/rest_test.go --- old/kargo-cli-1.10.7/pkg/server/rest_test.go 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/pkg/server/rest_test.go 2026-06-26 01:39:10.000000000 +0200 @@ -16,6 +16,7 @@ coordinationv1 "k8s.io/api/coordination/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -51,6 +52,21 @@ assertions func(*testing.T, *httptest.ResponseRecorder, client.Client) } +// testRESTMapper builds a RESTMapper from a scheme so fake clients can resolve a +// resource type's API group the way the production discovery-backed mapper does. +// It mirrors a real cluster by omitting rbac.kargo.akuity.io types, which are +// virtual API objects rather than resources the cluster actually serves. +func testRESTMapper(s *runtime.Scheme) meta.RESTMapper { + m := meta.NewDefaultRESTMapper(nil) + for gvk := range s.AllKnownTypes() { + if strings.HasSuffix(gvk.Kind, "List") || gvk.Group == rbacapi.GroupVersion.Group { + continue + } + m.Add(gvk, meta.RESTScopeNamespace) + } + return m +} + func testRESTEndpoint( t *testing.T, serverCfg *config.ServerConfig, @@ -96,7 +112,10 @@ if testCase.clientBuilder == nil { testCase.clientBuilder = fake.NewClientBuilder() } - internalClient := testCase.clientBuilder.WithScheme(testScheme).Build() + internalClient := testCase.clientBuilder. + WithScheme(testScheme). + WithRESTMapper(testRESTMapper(testScheme)). + Build() s.client, err = kubernetes.NewClient( t.Context(), &rest.Config{}, @@ -208,7 +227,10 @@ if testCase.clientBuilder == nil { testCase.clientBuilder = fake.NewClientBuilder() } - internalClient := testCase.clientBuilder.WithScheme(testScheme).Build() + internalClient := testCase.clientBuilder. + WithScheme(testScheme). + WithRESTMapper(testRESTMapper(testScheme)). + Build() var err error s.client, err = kubernetes.NewClient( t.Context(), diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/ui/src/features/project/settings/views/project-config/project-config.tsx new/kargo-cli-1.10.8/ui/src/features/project/settings/views/project-config/project-config.tsx --- old/kargo-cli-1.10.7/ui/src/features/project/settings/views/project-config/project-config.tsx 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/ui/src/features/project/settings/views/project-config/project-config.tsx 2026-06-26 01:39:10.000000000 +0200 @@ -14,7 +14,7 @@ import { useModal } from '@ui/features/common/modal/use-modal'; import { projectConfigYAMLExample } from '@ui/features/project/list/utils/project-yaml-example'; import { useGetProjectConfig } from '@ui/gen/api/v2/core/core'; -import { useUpdateResource } from '@ui/gen/api/v2/resources/resources'; +import { useCreateResource, useUpdateResource } from '@ui/gen/api/v2/resources/resources'; import projectConfigSchema from '@ui/gen/schema/projectconfigs.kargo.akuity.io_v1alpha1.json'; import { zodValidators } from '@ui/utils/validators'; @@ -73,14 +73,19 @@ resolver: zodResolver(formSchema) }); - const createOrUpdateMutation = useUpdateResource({ + const mutationOptions = { mutation: { onSuccess: () => { message.success({ content: `ProjectConfig has been ${creation ? 'created' : 'updated'}` }); projectConfigQuery.refetch(); } } - }); + }; + + const createMutation = useCreateResource(mutationOptions); + const updateMutation = useUpdateResource(mutationOptions); + + const createOrUpdateMutation = creation ? createMutation : updateMutation; const onSubmitConfig = projectConfigForm.handleSubmit((data) => createOrUpdateMutation.mutate({ data: data.value }) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/kargo-cli-1.10.7/ui/src/features/settings/cluster-config/cluster-config.tsx new/kargo-cli-1.10.8/ui/src/features/settings/cluster-config/cluster-config.tsx --- old/kargo-cli-1.10.7/ui/src/features/settings/cluster-config/cluster-config.tsx 2026-06-12 02:30:12.000000000 +0200 +++ new/kargo-cli-1.10.8/ui/src/features/settings/cluster-config/cluster-config.tsx 2026-06-26 01:39:10.000000000 +0200 @@ -14,7 +14,7 @@ import { FieldContainer } from '@ui/features/common/form/field-container'; import { useModal } from '@ui/features/common/modal/use-modal'; import { Webhooks } from '@ui/features/project/settings/views/project-config/webhooks'; -import { useUpdateResource } from '@ui/gen/api/v2/resources/resources'; +import { useCreateResource, useUpdateResource } from '@ui/gen/api/v2/resources/resources'; import { useGetClusterConfig } from '@ui/gen/api/v2/system/system'; import clusterConfigSchema from '@ui/gen/schema/clusterconfigs.kargo.akuity.io_v1alpha1.json'; import { zodValidators } from '@ui/utils/validators'; @@ -75,7 +75,7 @@ resolver: zodResolver(formSchema) }); - const updateResourceMutation = useUpdateResource({ + const mutationOptions = { mutation: { onSuccess: () => { message.success({ @@ -84,14 +84,19 @@ getClusterConfigQuery.refetch(); } } - }); + }; + + const createMutation = useCreateResource(mutationOptions); + const updateMutation = useUpdateResource(mutationOptions); + + const createOrUpdateMutation = creation ? createMutation : updateMutation; const createWebhookModal = useModal((props) => ( <CreateWebhookModal clusterConfigYAML={clusterConfigYAML} project={name || ''} {...props} /> )); const onSubmitConfig = clusterConfigForm.handleSubmit((data) => - updateResourceMutation.mutate({ data: data.value }) + createOrUpdateMutation.mutate({ data: data.value }) ); return ( @@ -121,7 +126,7 @@ className='ml-auto' type='primary' onClick={onSubmitConfig} - loading={updateResourceMutation.isPending} + loading={createOrUpdateMutation.isPending} > {creation ? 'Create' : 'Update'} </Button> ++++++ kargo-cli.obsinfo ++++++ --- /var/tmp/diff_new_pack.KAIf8x/_old 2026-07-01 16:52:22.561863278 +0200 +++ /var/tmp/diff_new_pack.KAIf8x/_new 2026-07-01 16:52:22.565863417 +0200 @@ -1,5 +1,5 @@ name: kargo-cli -version: 1.10.7 -mtime: 1781224212 -commit: 17077e406ed309fcc830d08776d9b9a47fe6e134 +version: 1.10.8 +mtime: 1782430750 +commit: 50d29c10153965114f903645c83eadbe7bf58d3f ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/kargo-cli/vendor.tar.gz /work/SRC/openSUSE:Factory/.kargo-cli.new.11887/vendor.tar.gz differ: char 13, line 1
