Skip to content
Merged
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
38 changes: 9 additions & 29 deletions components/backend/.golangci.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,20 @@
# golangci-lint configuration for vTeam backend
# Compatible with golangci-lint v2+
# Compatible with golangci-lint v2.x
#
# This configuration is pragmatic for a Kubernetes-native application.
# We focus on catching real bugs rather than style issues.

version: "2"

run:
timeout: 5m

linters:
enable:
- govet # Reports suspicious constructs
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis (includes many useful checks)
- unused # Check for unused constants, variables, functions
- misspell # Find commonly misspelled words
- govet
- ineffassign
- staticcheck
- unused
- misspell
disable:
- errcheck # Disabled: too many false positives with defer cleanup

linters-settings:
staticcheck:
checks: ["all", "-SA1019"] # Disable deprecation warnings only

issues:
max-issues-per-linter: 0 # Show all issues
max-same-issues: 0 # Show all instances

exclude-rules:
# Exclude all linters from test files
- path: _test\.go
linters:
- staticcheck
- govet

# Allow type assertions in K8s unstructured object parsing (intentional pattern)
- path: (handlers|jira)/.*\.go
text: "type assertion"

# Show all issues
new: false
- errcheck
108 changes: 103 additions & 5 deletions components/backend/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import (
"math"
"time"

"github.com/gin-gonic/gin"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)

// GetProjectSettingsResource returns the GroupVersionResource for ProjectSettings
// GetProjectSettingsResource returns the GroupVersionResource for ProjectSettings CRD.
// Returns a GVR for group "vteam.ambient-code", version "v1alpha1", resource "projectsettings".
func GetProjectSettingsResource() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: "vteam.ambient-code",
Expand All @@ -18,10 +22,16 @@ func GetProjectSettingsResource() schema.GroupVersionResource {
}
}

// RetryWithBackoff attempts an operation with exponential backoff
// Used for operations that may temporarily fail due to async resource creation
// This is a generic utility that can be used by any handler
// Checks for context cancellation between retries to avoid wasting resources
// RetryWithBackoff attempts an operation with exponential backoff.
// Used for operations that may temporarily fail due to async resource creation.
//
// Parameters:
// - maxRetries: Maximum number of retry attempts
// - initialDelay: Initial delay duration before first retry
// - maxDelay: Maximum delay duration cap for exponential backoff
// - operation: Function to execute that returns an error if it fails
//
// Returns an error if all retries are exhausted, nil if operation succeeds.
func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, operation func() error) error {
var lastErr error
for i := 0; i < maxRetries; i++ {
Expand All @@ -43,3 +53,91 @@ func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, oper
}
return fmt.Errorf("operation failed after %d retries: %w", maxRetries, lastErr)
}

// GetMetadataMap safely extracts the metadata field from an unstructured Kubernetes object.
// This function provides type-safe access with nil checking to prevent runtime panics.
//
// Parameters:
// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource)
//
// Returns:
// - map[string]interface{}: The metadata map if extraction succeeds, nil otherwise
// - bool: true if metadata was successfully extracted and has the expected type, false otherwise
//
// Use this instead of unsafe type assertions like: obj.Object["metadata"].(map[string]interface{})
func GetMetadataMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) {
if obj == nil || obj.Object == nil {
return nil, false
}
metadata, ok := obj.Object["metadata"].(map[string]interface{})
return metadata, ok
}

// GetSpecMap safely extracts the spec field from an unstructured Kubernetes object.
// This function provides type-safe access with nil checking to prevent runtime panics.
//
// Parameters:
// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource)
//
// Returns:
// - map[string]interface{}: The spec map if extraction succeeds, nil otherwise
// - bool: true if spec was successfully extracted and has the expected type, false otherwise
//
// Use this instead of unsafe type assertions like: obj.Object["spec"].(map[string]interface{})
func GetSpecMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) {
if obj == nil || obj.Object == nil {
return nil, false
}
spec, ok := obj.Object["spec"].(map[string]interface{})
return spec, ok
}

// GetStatusMap safely extracts the status field from an unstructured Kubernetes object.
// This function provides type-safe access with nil checking to prevent runtime panics.
//
// Parameters:
// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource)
//
// Returns:
// - map[string]interface{}: The status map if extraction succeeds, nil otherwise
// - bool: true if status was successfully extracted and has the expected type, false otherwise
//
// Use this instead of unsafe type assertions like: obj.Object["status"].(map[string]interface{})
func GetStatusMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) {
if obj == nil || obj.Object == nil {
return nil, false
}
status, ok := obj.Object["status"].(map[string]interface{})
return status, ok
}

// ResolveContentServiceName determines the correct content service name for a session.
// This function handles the logic of choosing between temp-content and ambient-content services.
//
// For completed/stopped sessions, a temporary service (temp-content-{session}) is created.
// For running sessions, the regular service (ambient-content-{session}) is used.
// This function tries the temp service first, and falls back to the regular service if not found.
//
// Parameters:
// - c: Gin context for the current HTTP request
// - project: The Kubernetes namespace/project name
// - session: The agentic session name
//
// Returns:
// - string: The resolved service name (either temp-content-{session} or ambient-content-{session})
func ResolveContentServiceName(c *gin.Context, project, session string) string {
// Try temp service first (for completed sessions)
tempServiceName := fmt.Sprintf("temp-content-%s", session)

reqK8s, _ := GetK8sClientsForRequest(c)
if reqK8s != nil {
// Check if temp service exists
if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), tempServiceName, v1.GetOptions{}); err == nil {
// Temp service exists, use it
return tempServiceName
}
}

// Fall back to regular service (for running sessions or if temp service doesn't exist)
return fmt.Sprintf("ambient-content-%s", session)
}
108 changes: 108 additions & 0 deletions components/backend/handlers/projects_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Package handlers provides HTTP handlers for the backend API.
// This file contains authentication and authorization functions for project management.
package handlers

import (
"context"
"fmt"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
authv1 "k8s.io/api/authorization/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)

// GetClusterInfo handles GET /cluster-info
// Returns information about the cluster type (OpenShift vs vanilla Kubernetes)
// This endpoint does not require authentication as it's public cluster information
func GetClusterInfo(c *gin.Context) {
isOpenShift := isOpenShiftCluster()

c.JSON(http.StatusOK, gin.H{
"isOpenShift": isOpenShift,
})
}

// checkUserCanAccessNamespace uses SelfSubjectAccessReview to verify if user can access a namespace
// This is the proper Kubernetes-native way - lets RBAC engine determine access from ALL sources
// (RoleBindings, ClusterRoleBindings, groups, etc.)
func checkUserCanAccessNamespace(userClient *kubernetes.Clientset, namespace string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// Check if user can list agenticsessions in the namespace (a good proxy for project access)
ssar := &authv1.SelfSubjectAccessReview{
Spec: authv1.SelfSubjectAccessReviewSpec{
ResourceAttributes: &authv1.ResourceAttributes{
Namespace: namespace,
Verb: "list",
Group: "vteam.ambient-code",
Resource: "agenticsessions",
},
},
}

result, err := userClient.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, v1.CreateOptions{})
if err != nil {
return false, err
}

return result.Status.Allowed, nil
}

// getUserSubjectFromContext extracts the user subject from the JWT token in the request
// Returns subject in format like "user@example.com" or "system:serviceaccount:namespace:name"
func getUserSubjectFromContext(c *gin.Context) (string, error) {
// Try to extract from ServiceAccount first
ns, saName, ok := ExtractServiceAccountFromAuth(c)
if ok {
return fmt.Sprintf("system:serviceaccount:%s:%s", ns, saName), nil
}

// Otherwise try to get from context (set by middleware)
if userName, exists := c.Get("userName"); exists && userName != nil {
return fmt.Sprintf("%v", userName), nil
}
if userID, exists := c.Get("userID"); exists && userID != nil {
return fmt.Sprintf("%v", userID), nil
}

return "", fmt.Errorf("no user subject found in token")
}

// getUserSubjectKind returns "ServiceAccount" or "User" based on the subject format
func getUserSubjectKind(subject string) string {
if len(subject) > 22 && subject[:22] == "system:serviceaccount:" {
return "ServiceAccount"
}
return "User"
}

// getUserSubjectName returns the name part of the subject
// For ServiceAccount: "system:serviceaccount:namespace:name" -> "name"
// For User: returns the subject as-is
func getUserSubjectName(subject string) string {
if getUserSubjectKind(subject) == "ServiceAccount" {
parts := strings.Split(subject, ":")
if len(parts) >= 4 {
return parts[3]
}
}
return subject
}

// getUserSubjectNamespace returns the namespace for ServiceAccount subjects
// For ServiceAccount: "system:serviceaccount:namespace:name" -> "namespace"
// For User: returns empty string
func getUserSubjectNamespace(subject string) string {
if getUserSubjectKind(subject) == "ServiceAccount" {
parts := strings.Split(subject, ":")
if len(parts) >= 3 {
return parts[2]
}
}
return ""
}
Loading
Loading