Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
536 changes: 536 additions & 0 deletions components/backend/handlers/gerrit_auth.go

Large diffs are not rendered by default.

448 changes: 448 additions & 0 deletions components/backend/handlers/gerrit_auth_test.go

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions components/backend/handlers/integration_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -161,6 +162,137 @@ func ValidateGoogleToken(ctx context.Context, accessToken string) (bool, error)
return resp.StatusCode == http.StatusOK, nil
}

// ValidateGerritToken checks if Gerrit credentials are valid
// Uses /a/accounts/self endpoint which accepts Basic Auth or Cookie-based auth
// Gerrit REST API responses are prefixed with )]}' (XSSI protection)
func ValidateGerritToken(ctx context.Context, gerritURL, authMethod, username, httpToken, gitcookiesContent string) (bool, error) {
if gerritURL == "" {
return false, fmt.Errorf("Gerrit URL is required")
}

client := &http.Client{
Timeout: 15 * time.Second,
Transport: ssrfSafeTransport(),
}
apiURL := fmt.Sprintf("%s/a/accounts/self", gerritURL)

req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return false, fmt.Errorf("failed to create request")
}
req.Header.Set("Accept", "application/json")

switch authMethod {
case "http_basic":
if username == "" || httpToken == "" {
return false, fmt.Errorf("username and HTTP token are required for HTTP basic auth")
}
req.SetBasicAuth(username, httpToken)

case "git_cookies":
if gitcookiesContent == "" {
return false, fmt.Errorf("gitcookies content is required")
}
// Parse gitcookies content to extract cookie for the target host
cookie := parseGitcookies(gerritURL, gitcookiesContent)
if cookie == "" {
return false, fmt.Errorf("no matching cookie found for host in gitcookies content")
}
req.Header.Set("Cookie", cookie)

default:
return false, fmt.Errorf("unsupported auth method: %s", authMethod)
}

resp, err := client.Do(req)
if err != nil {
// Don't wrap error - could leak credentials from request details
return false, fmt.Errorf("request failed")
}
defer resp.Body.Close()

// 200 = valid credentials; anything else (including 5xx, redirects,
// wrong base URL) is treated as invalid to fail closed.
if resp.StatusCode == http.StatusOK {
return true, nil
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return false, nil
}

return false, fmt.Errorf("unexpected status %d from Gerrit /a/accounts/self", resp.StatusCode)
}

// parseGitcookies extracts the cookie value for a given Gerrit URL from gitcookies content.
// Gitcookies format: host\tsubdomain_flag\t/\tTRUE\t2147483647\to\tvalue
// The subdomain flag (column 2) controls whether subdomains are allowed:
// "TRUE" means any subdomain matches; "FALSE" means exact host match only.
func parseGitcookies(gerritURL, content string) string {
parsed, err := url.Parse(gerritURL)
if err != nil {
return ""
}
host := parsed.Hostname()

lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) >= 7 {
cookieHost := strings.TrimPrefix(fields[0], ".")
subdomainFlag := strings.ToUpper(strings.TrimSpace(fields[1]))

if cookieHost == host {
return fields[5] + "=" + fields[6]
}
// Only allow subdomain matching when the flag is TRUE
if subdomainFlag == "TRUE" && strings.HasSuffix(host, "."+cookieHost) {
return fields[5] + "=" + fields[6]
}
}
}
return ""
}

// TestGerritConnection handles POST /api/auth/gerrit/test
// Tests Gerrit credentials without saving them
func TestGerritConnection(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
AuthMethod string `json:"authMethod" binding:"required"`
Username string `json:"username"`
HTTPToken string `json:"httpToken"`
GitcookiesContent string `json:"gitcookiesContent"`
}

if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Validate URL (SSRF protection)
if err := validateGerritURL(req.URL); err != nil {
c.JSON(http.StatusOK, gin.H{"valid": false, "error": fmt.Sprintf("Invalid Gerrit URL: %s", err.Error())})
return
}

valid, err := validateGerritTokenFn(c.Request.Context(), req.URL, req.AuthMethod, req.Username, req.HTTPToken, req.GitcookiesContent)
if err != nil {
c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()})
return
}

if !valid {
c.JSON(http.StatusOK, gin.H{"valid": false, "error": "Invalid credentials"})
return
}

c.JSON(http.StatusOK, gin.H{"valid": true, "message": "Gerrit connection successful"})
}

// TestJiraConnection handles POST /api/auth/jira/test
// Tests Jira credentials without saving them
func TestJiraConnection(c *gin.Context) {
Expand Down
37 changes: 37 additions & 0 deletions components/backend/handlers/integrations_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"sort"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -39,6 +40,9 @@ func GetIntegrationsStatus(c *gin.Context) {
// GitLab status
response["gitlab"] = getGitLabStatusForUser(ctx, userID)

// Gerrit status
response["gerrit"] = getGerritStatusForUser(ctx, userID)

// MCP server credentials status
response["mcpServers"] = getMCPServerStatusForUser(ctx, userID)

Expand All @@ -47,6 +51,7 @@ func GetIntegrationsStatus(c *gin.Context) {

// Helper functions to get individual integration statuses

// getGitHubStatusForUser returns the GitHub integration status (App + PAT) for a user.
func getGitHubStatusForUser(ctx context.Context, userID string) gin.H {
log.Printf("getGitHubStatusForUser: querying status for user=%s", userID)
status := gin.H{
Expand Down Expand Up @@ -90,6 +95,7 @@ func getGitHubStatusForUser(ctx context.Context, userID string) gin.H {
return status
}

// getGoogleStatusForUser returns the Google OAuth integration status for a user.
func getGoogleStatusForUser(ctx context.Context, userID string) gin.H {
creds, err := GetGoogleCredentials(ctx, userID)
if err != nil || creds == nil {
Expand All @@ -108,6 +114,7 @@ func getGoogleStatusForUser(ctx context.Context, userID string) gin.H {
}
}

// getJiraStatusForUser returns the Jira integration status for a user.
func getJiraStatusForUser(ctx context.Context, userID string) gin.H {
creds, err := GetJiraCredentials(ctx, userID)
if err != nil || creds == nil {
Expand All @@ -128,6 +135,36 @@ func getJiraStatusForUser(ctx context.Context, userID string) gin.H {
}
}

// getGerritStatusForUser returns the Gerrit integration status with all connected instances for a user.
func getGerritStatusForUser(ctx context.Context, userID string) gin.H {
instances, err := listGerritCredentials(ctx, userID)
if err != nil {
log.Printf("Failed to list Gerrit instances for user %s: %v", userID, err)
return gin.H{"instances": []gin.H{}}
}
if len(instances) == 0 {
return gin.H{"instances": []gin.H{}}
}

sort.Slice(instances, func(i, j int) bool {
return instances[i].InstanceName < instances[j].InstanceName
})

result := make([]gin.H, 0, len(instances))
for _, creds := range instances {
result = append(result, gin.H{
"connected": true,
"instanceName": creds.InstanceName,
"url": creds.URL,
"authMethod": creds.AuthMethod,
"updatedAt": creds.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}

return gin.H{"instances": result}
}

// getGitLabStatusForUser returns the GitLab integration status for a user.
func getGitLabStatusForUser(ctx context.Context, userID string) gin.H {
creds, err := GetGitLabCredentials(ctx, userID)
if err != nil || creds == nil {
Expand Down
49 changes: 49 additions & 0 deletions components/backend/handlers/runtime_credentials.go
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,55 @@ func fetchGitHubUserIdentity(ctx context.Context, token string) (userName, email
return userName, email
}

// GetGerritCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/gerrit
// Returns all Gerrit instances' credentials for the session's user
func GetGerritCredentialsForSession(c *gin.Context) {
project := c.Param("projectName")
session := c.Param("sessionName")

reqK8s, reqDyn := GetK8sClientsForRequest(c)
if reqK8s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
return
}

effectiveUserID, ok := enforceCredentialRBAC(c, reqK8s, reqDyn, project, session)
if !ok {
return
}

// Get all Gerrit instances for the user
instances, err := listGerritCredentials(c.Request.Context(), effectiveUserID)
if err != nil {
log.Printf("Failed to get Gerrit credentials for user %s: %v", effectiveUserID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Gerrit credentials"})
return
}

if len(instances) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Gerrit credentials not configured"})
return
}

result := make([]gin.H, 0, len(instances))
for _, creds := range instances {
instance := gin.H{
"instanceName": creds.InstanceName,
"url": creds.URL,
"authMethod": creds.AuthMethod,
}
if creds.AuthMethod == "http_basic" {
instance["username"] = creds.Username
instance["httpToken"] = creds.HTTPToken
} else if creds.AuthMethod == "git_cookies" {
instance["gitcookiesContent"] = creds.GitcookiesContent
}
result = append(result, instance)
}

c.JSON(http.StatusOK, gin.H{"instances": result})
Comment on lines +514 to +530
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sort Gerrit instances before returning to keep credential responses deterministic.

At Line 515, response order currently depends on upstream map iteration order from listGerritCredentials, which is non-deterministic. This can produce unstable runner config ordering across requests.

Proposed fix
 import (
 	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log"
 	"net/http"
 	"net/url"
+	"sort"
 	"strings"
 	"sync"
 	"time"
@@
 	if len(instances) == 0 {
 		c.JSON(http.StatusNotFound, gin.H{"error": "Gerrit credentials not configured"})
 		return
 	}
 
+	sort.Slice(instances, func(i, j int) bool {
+		if instances[i].InstanceName == instances[j].InstanceName {
+			return instances[i].URL < instances[j].URL
+		}
+		return instances[i].InstanceName < instances[j].InstanceName
+	})
+
 	result := make([]gin.H, 0, len(instances))
 	for _, creds := range instances {
As per coding guidelines: "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
result := make([]gin.H, 0, len(instances))
for _, creds := range instances {
instance := gin.H{
"instanceName": creds.InstanceName,
"url": creds.URL,
"authMethod": creds.AuthMethod,
}
if creds.AuthMethod == "http_basic" {
instance["username"] = creds.Username
instance["httpToken"] = creds.HTTPToken
} else if creds.AuthMethod == "git_cookies" {
instance["gitcookiesContent"] = creds.GitcookiesContent
}
result = append(result, instance)
}
c.JSON(http.StatusOK, gin.H{"instances": result})
sort.Slice(instances, func(i, j int) bool {
if instances[i].InstanceName == instances[j].InstanceName {
return instances[i].URL < instances[j].URL
}
return instances[i].InstanceName < instances[j].InstanceName
})
result := make([]gin.H, 0, len(instances))
for _, creds := range instances {
instance := gin.H{
"instanceName": creds.InstanceName,
"url": creds.URL,
"authMethod": creds.AuthMethod,
}
if creds.AuthMethod == "http_basic" {
instance["username"] = creds.Username
instance["httpToken"] = creds.HTTPToken
} else if creds.AuthMethod == "git_cookies" {
instance["gitcookiesContent"] = creds.GitcookiesContent
}
result = append(result, instance)
}
c.JSON(http.StatusOK, gin.H{"instances": result})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/backend/handlers/runtime_credentials.go` around lines 514 - 530,
The response is non-deterministic because the loop over instances (returned by
listGerritCredentials) preserves map iteration order; before building result in
the runtime_credentials.go handler (the block that constructs instance :=
gin.H{...} and appends to result), sort the instances slice by a stable key
(e.g., creds.InstanceName) using sort.SliceStable to guarantee deterministic
ordering, then iterate the sorted slice to build result and return it via
c.JSON; this ensures consistent Gerrit instance ordering across requests.

}

// fetchGitLabUserIdentity fetches user name and email from GitLab API
// Returns the user's name and email for git config
func fetchGitLabUserIdentity(ctx context.Context, token, instanceURL string) (userName, email string) {
Expand Down
12 changes: 9 additions & 3 deletions components/backend/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func registerRoutes(r *gin.Engine) {
api := r.Group("/api")
{
// Public endpoints (no auth required)
api.GET("/version", handlers.GetVersion)
api.GET("/workflows/ootb", handlers.ListOOTBWorkflows)
// Global runner-types endpoint (no workspace overrides — for admin pages)
api.GET("/runner-types", handlers.GetRunnerTypesGlobal)
Expand Down Expand Up @@ -88,6 +89,7 @@ func registerRoutes(r *gin.Engine) {
projectGroup.GET("/agentic-sessions/:sessionName/credentials/google", handlers.GetGoogleCredentialsForSession)
projectGroup.GET("/agentic-sessions/:sessionName/credentials/jira", handlers.GetJiraCredentialsForSession)
projectGroup.GET("/agentic-sessions/:sessionName/credentials/gitlab", handlers.GetGitLabTokenForSession)
projectGroup.GET("/agentic-sessions/:sessionName/credentials/gerrit", handlers.GetGerritCredentialsForSession)
projectGroup.GET("/agentic-sessions/:sessionName/credentials/mcp/:serverName", handlers.GetMCPCredentialsForSession)

// Session export
Expand Down Expand Up @@ -164,6 +166,13 @@ func registerRoutes(r *gin.Engine) {
api.DELETE("/auth/gitlab/disconnect", handlers.DisconnectGitLabGlobal)
api.POST("/auth/gitlab/test", handlers.TestGitLabConnection)

// Cluster-level Gerrit (user-scoped, multi-instance)
api.POST("/auth/gerrit/connect", handlers.ConnectGerrit)
api.POST("/auth/gerrit/test", handlers.TestGerritConnection)
api.GET("/auth/gerrit/instances", handlers.ListGerritInstances)
api.GET("/auth/gerrit/:instanceName/status", handlers.GetGerritStatus)
api.DELETE("/auth/gerrit/:instanceName/disconnect", handlers.DisconnectGerrit)

// Generic MCP server credentials (user-scoped)
api.POST("/auth/mcp/:serverName/connect", handlers.ConnectMCPServer)
api.GET("/auth/mcp/:serverName/status", handlers.GetMCPServerStatus)
Expand All @@ -172,9 +181,6 @@ func registerRoutes(r *gin.Engine) {
// Cluster info endpoint (public, no auth required)
api.GET("/cluster-info", handlers.GetClusterInfo)

// Version endpoint (public, no auth required)
api.GET("/version", handlers.GetVersion)

// LDAP search endpoints (cluster-scoped, auth-required)
api.GET("/ldap/users", handlers.SearchLDAPUsers)
api.GET("/ldap/users/:uid", handlers.GetLDAPUser)
Expand Down
1 change: 1 addition & 0 deletions components/backend/tests/constants/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
LabelPermissions = "permissions"
LabelProjects = "projects"
LabelGitHubAuth = "github-auth"
LabelGerritAuth = "gerrit-auth"
LabelGitLabAuth = "gitlab-auth"
LabelSessions = "sessions"
LabelContent = "content"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { BACKEND_URL } from '@/lib/config'
import { buildForwardHeadersAsync } from '@/lib/auth'

export async function DELETE(
request: Request,
{ params }: { params: Promise<{ instanceName: string }> }
) {
const { instanceName } = await params
const safeInstanceName = encodeURIComponent(instanceName)
const headers = await buildForwardHeadersAsync(request)

const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${safeInstanceName}/disconnect`, {
method: 'DELETE',
headers,
})

const data = await resp.text()
return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { BACKEND_URL } from '@/lib/config'
import { buildForwardHeadersAsync } from '@/lib/auth'

export async function GET(
request: Request,
{ params }: { params: Promise<{ instanceName: string }> }
) {
const { instanceName } = await params
const safeInstanceName = encodeURIComponent(instanceName)
const headers = await buildForwardHeadersAsync(request)

const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${safeInstanceName}/status`, {
method: 'GET',
headers,
})

const data = await resp.text()
return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
}
16 changes: 16 additions & 0 deletions components/frontend/src/app/api/auth/gerrit/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { BACKEND_URL } from '@/lib/config'
import { buildForwardHeadersAsync } from '@/lib/auth'

export async function POST(request: Request) {
const headers = await buildForwardHeadersAsync(request)
const body = await request.text()

const resp = await fetch(`${BACKEND_URL}/auth/gerrit/connect`, {
method: 'POST',
headers,
body,
})

const data = await resp.text()
return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
}
Loading