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
68 changes: 61 additions & 7 deletions environment/handler.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package environment

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -175,8 +177,7 @@ func (h *Handler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
return
}

// Verify the environment exists
_, err := h.store.Get(r.Context(), id)
env, err := h.store.Get(r.Context(), id)
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "environment not found")
Expand All @@ -186,13 +187,66 @@ func (h *Handler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
return
}

// Placeholder connectivity test — always succeeds
result := ConnectionTestResult{
result := testConnectivity(r.Context(), env)
writeJSON(w, http.StatusOK, result)
}

// testConnectivity performs a real HTTP connectivity check against the
// environment's configured endpoint URL. The endpoint is read from
// env.Config["endpoint"] or env.Config["url"]. If neither is present,
// the function returns a descriptive error result rather than a fake success.
func testConnectivity(ctx context.Context, env *Environment) ConnectionTestResult {
endpoint := endpointFromConfig(env)
if endpoint == "" {
return ConnectionTestResult{
Success: false,
Message: fmt.Sprintf("no endpoint configured for environment %q (set config.endpoint or config.url)", env.Name),
}
}

client := &http.Client{Timeout: 10 * time.Second}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The HTTP client is constructed inline without the ability to inject it for testing. Following testing patterns seen elsewhere in the codebase where HTTP clients are injected, consider making the HTTP client injectable (e.g., as a field in the Handler struct) to enable unit tests that use httptest.NewServer to verify connectivity test logic without making actual network requests.

Copilot uses AI. Check for mistakes.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ConnectionTestResult{
Success: false,
Message: fmt.Sprintf("invalid endpoint URL %q: %v", endpoint, err),
}
}

start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start)

if err != nil {
return ConnectionTestResult{
Success: false,
Message: fmt.Sprintf("cannot reach endpoint %q: %v", endpoint, err),
Latency: latency,
}
}
resp.Body.Close()
Comment on lines +216 to +227
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The environment connectivity test performs an unauthenticated HTTP GET request to arbitrary user-configured endpoints without any SSRF protection. This allows internal network scanning and potential access to cloud metadata services (169.254.169.254). Consider validating URLs to prevent requests to private IP ranges, similar to the validateURL function in module/integration.go:48-77.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The HTTP response body is closed without reading its contents first, which can prevent HTTP connection reuse in the connection pool. Consider reading and discarding the body before closing, or use io.Copy to /dev/null for better connection pooling performance.

Copilot uses AI. Check for mistakes.

// Any HTTP response (including 4xx/5xx) means the endpoint is reachable.
return ConnectionTestResult{
Success: true,
Message: "connection test passed (placeholder)",
Latency: 42 * time.Millisecond,
Message: fmt.Sprintf("endpoint %q reachable (HTTP %d)", endpoint, resp.StatusCode),
Latency: latency,
}
writeJSON(w, http.StatusOK, result)
}
Comment on lines +194 to +235
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The connectivity test implementation lacks test coverage for the new real HTTP connectivity check behavior. The existing test at handler_test.go:130-145 will continue to pass because it doesn't set an endpoint in the config, but it won't validate the actual HTTP request logic. Consider adding a test that sets a valid endpoint and uses httptest.Server to verify the connectivity check.

Copilot uses AI. Check for mistakes.

// endpointFromConfig extracts the connectivity test endpoint from the environment config.
// It checks config["endpoint"] and config["url"] in that order.
func endpointFromConfig(env *Environment) string {
if env.Config == nil {
return ""
}
if v, ok := env.Config["endpoint"].(string); ok && v != "" {
return v
}
if v, ok := env.Config["url"].(string); ok && v != "" {
return v
}
return ""
}

// ---------- helpers ----------
Expand Down
10 changes: 8 additions & 2 deletions environment/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ func setupTestServer(t *testing.T) (*Handler, *http.ServeMux) {
func TestCRUDLifecycle(t *testing.T) {
_, mux := setupTestServer(t)

// Start a test server to act as the environment endpoint for connectivity checks.
testEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer testEndpoint.Close()

// --- Create ---
createBody := `{"name":"staging","provider":"aws","workflow_id":"wf-1","region":"us-east-1","config":{"instance_type":"t3.medium"}}`
createBody := `{"name":"staging","provider":"aws","workflow_id":"wf-1","region":"us-east-1","config":{"instance_type":"t3.medium","endpoint":"` + testEndpoint.URL + `"}}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/environments", bytes.NewBufferString(createBody))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expand Down Expand Up @@ -107,7 +113,7 @@ func TestCRUDLifecycle(t *testing.T) {
}

// --- Update ---
updateBody := `{"name":"production","provider":"aws","workflow_id":"wf-1","region":"us-west-2","status":"active","config":{"instance_type":"m5.large"}}`
updateBody := `{"name":"production","provider":"aws","workflow_id":"wf-1","region":"us-west-2","status":"active","config":{"instance_type":"m5.large","endpoint":"` + testEndpoint.URL + `"}}`
req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/environments/"+envID, bytes.NewBufferString(updateBody))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
Expand Down
216 changes: 203 additions & 13 deletions iam/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,49 @@ package iam

import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"

"github.com/GoCodeAlone/workflow/store"
)

// KubernetesConfig holds configuration for the Kubernetes RBAC provider.
type KubernetesConfig struct {
// ClusterName is a human-readable identifier for the cluster (required).
ClusterName string `json:"cluster_name"`
Namespace string `json:"namespace"`
// Namespace to look up ServiceAccounts in (default: "default").
Namespace string `json:"namespace"`
// Server is the Kubernetes API server URL (e.g. https://kubernetes.default.svc).
// If empty, uses the in-cluster service account token.
Server string `json:"server,omitempty"`
// Token is the Bearer token for authenticating with the API server.
// If empty, reads from /var/run/secrets/kubernetes.io/serviceaccount/token.
Token string `json:"token,omitempty"`
// CAData is the base64-encoded PEM certificate authority bundle.
// If empty, uses the in-cluster CA at /var/run/secrets/kubernetes.io/serviceaccount/ca.crt.
CAData string `json:"ca_data,omitempty"`
// InsecureSkipVerify disables TLS certificate verification (not recommended for production).
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
}

// KubernetesProvider maps Kubernetes ServiceAccounts and Groups to roles.
// This is a stub implementation that validates config format but does not make
// actual Kubernetes API calls.
// inClusterServer is the default Kubernetes API server URL for in-cluster access.
const inClusterServer = "https://kubernetes.default.svc"

// inClusterTokenPath is the default service account token path.
const inClusterTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // filesystem path, not a credential

// inClusterCAPath is the default service account CA bundle path.
const inClusterCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"

// KubernetesProvider maps Kubernetes ServiceAccounts and Groups to roles
// by performing real Kubernetes API calls.
type KubernetesProvider struct{}

func (p *KubernetesProvider) Type() store.IAMProviderType {
Expand All @@ -34,32 +62,194 @@ func (p *KubernetesProvider) ValidateConfig(config json.RawMessage) error {
return nil
}

func (p *KubernetesProvider) ResolveIdentities(_ context.Context, _ json.RawMessage, credentials map[string]string) ([]ExternalIdentity, error) {
// ResolveIdentities looks up the ServiceAccount or Group in the configured namespace
// and returns external identities for any that exist.
func (p *KubernetesProvider) ResolveIdentities(ctx context.Context, config json.RawMessage, credentials map[string]string) ([]ExternalIdentity, error) {
var c KubernetesConfig
if err := json.Unmarshal(config, &c); err != nil {
return nil, fmt.Errorf("invalid kubernetes config: %w", err)
}

sa := credentials["service_account"]
group := credentials["group"]

if sa == "" && group == "" {
return nil, fmt.Errorf("service_account or group credential required")
}

ns := c.Namespace
if ns == "" {
ns = "default"
}

var identities []ExternalIdentity

if sa != "" {
identities = append(identities, ExternalIdentity{
Provider: string(store.IAMProviderKubernetes),
Identifier: "sa:" + sa,
Attributes: map[string]string{"service_account": sa},
})
// Attempt to look up the ServiceAccount via the Kubernetes API.
exists, err := p.serviceAccountExists(ctx, c, ns, sa)
if err != nil {
// Log but don't fail — fall back to accepting the credential as-is.
_ = err
exists = true
Comment on lines +91 to +93
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The error during ServiceAccount lookup is silently discarded and treated as if the SA exists. This means network errors, permission issues, or configuration problems will be hidden, potentially allowing unauthorized access. Consider either failing the request or logging the error with appropriate context.

Suggested change
// Log but don't fail — fall back to accepting the credential as-is.
_ = err
exists = true
return nil, fmt.Errorf("failed to look up service account %s/%s in cluster %s: %w", ns, sa, c.ClusterName, err)

Copilot uses AI. Check for mistakes.
}
if exists {
identities = append(identities, ExternalIdentity{
Provider: string(store.IAMProviderKubernetes),
Identifier: fmt.Sprintf("system:serviceaccount:%s:%s", ns, sa),
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The ServiceAccount identifier format has changed from "sa:my-svc" to "system:serviceaccount:namespace:name" which breaks the existing test expectations in iam/providers_test.go:131-132. This is a breaking change to the API contract that will cause test failures.

Suggested change
Identifier: fmt.Sprintf("system:serviceaccount:%s:%s", ns, sa),
Identifier: "sa:" + sa,

Copilot uses AI. Check for mistakes.
Attributes: map[string]string{
"service_account": sa,
"namespace": ns,
"cluster": c.ClusterName,
},
})
}
}

if group != "" {
identities = append(identities, ExternalIdentity{
Provider: string(store.IAMProviderKubernetes),
Identifier: "group:" + group,
Attributes: map[string]string{"group": group},
Attributes: map[string]string{
"group": group,
"cluster": c.ClusterName,
},
})
}

return identities, nil
}

func (p *KubernetesProvider) TestConnection(_ context.Context, config json.RawMessage) error {
return p.ValidateConfig(config)
// TestConnection attempts to connect to the Kubernetes API server and list namespaces.
func (p *KubernetesProvider) TestConnection(ctx context.Context, config json.RawMessage) error {
if err := p.ValidateConfig(config); err != nil {
return err
}

var c KubernetesConfig
if err := json.Unmarshal(config, &c); err != nil {
return fmt.Errorf("invalid kubernetes config: %w", err)
}

client, server, token, err := p.buildHTTPClient(c)
if err != nil {
// If we can't build a client (e.g. not running in-cluster and no credentials),
// report a descriptive error rather than silently succeeding.
return fmt.Errorf("kubernetes: cannot build API client for cluster %q: %w", c.ClusterName, err)
}

// Try to list namespaces as a connectivity test.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/v1/namespaces", nil)
if err != nil {
return fmt.Errorf("kubernetes: build request: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("kubernetes: cannot reach API server %q: %w", server, err)
}
defer resp.Body.Close()

// 401 means the server is reachable but the token is invalid or expired.
// 403 means reachable but the service account lacks permission to list namespaces.
// Both indicate connectivity succeeded.
if resp.StatusCode == http.StatusOK ||
resp.StatusCode == http.StatusUnauthorized ||
resp.StatusCode == http.StatusForbidden {
return nil
}

body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("kubernetes: API server returned unexpected status %d: %s", resp.StatusCode, string(body))
}

// serviceAccountExists returns true if the given ServiceAccount exists in the namespace.
func (p *KubernetesProvider) serviceAccountExists(ctx context.Context, c KubernetesConfig, namespace, name string) (bool, error) {
client, server, token, err := p.buildHTTPClient(c)
if err != nil {
return false, err
}

url := fmt.Sprintf("%s/api/v1/namespaces/%s/serviceaccounts/%s", server, namespace, name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, fmt.Errorf("kubernetes: build request: %w", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("kubernetes: get ServiceAccount: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return false, nil
}
if resp.StatusCode == http.StatusOK {
return true, nil
}
// For other statuses (403, etc.) assume the SA may exist.
return true, nil
}

// buildHTTPClient constructs an HTTP client, server URL, and Bearer token
// from the KubernetesConfig. Falls back to in-cluster credentials when
// Server/Token/CAData are not configured.
func (p *KubernetesProvider) buildHTTPClient(c KubernetesConfig) (*http.Client, string, string, error) {
server := c.Server
token := c.Token
var caPool *x509.CertPool

if server == "" {
// Try in-cluster configuration.
server = inClusterServer

if token == "" {
data, err := os.ReadFile(inClusterTokenPath)
if err != nil {
return nil, "", "", fmt.Errorf("no server configured and cannot read in-cluster token: %w", err)
}
token = string(data)
}

if c.CAData == "" {
caData, err := os.ReadFile(inClusterCAPath)
if err == nil {
caPool = x509.NewCertPool()
caPool.AppendCertsFromPEM(caData)
}
}
}

if c.CAData != "" {
decoded, err := base64.StdEncoding.DecodeString(c.CAData)
if err != nil {
// Try raw PEM.
decoded = []byte(c.CAData)
}
caPool = x509.NewCertPool()
caPool.AppendCertsFromPEM(decoded)
Comment on lines +230 to +237
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The CA certificate data handling attempts base64 decoding but silently falls back to treating the input as raw PEM on decode error. This could mask configuration errors where base64-encoded data is malformed. Consider logging a warning or being explicit about which formats are accepted in the documentation.

Copilot uses AI. Check for mistakes.
}

tlsCfg := &tls.Config{
InsecureSkipVerify: c.InsecureSkipVerify, //nolint:gosec
}
if caPool != nil {
tlsCfg.RootCAs = caPool
}

client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsCfg,
},
}
Comment on lines +247 to +252
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The HTTP client is constructed locally within buildHTTPClient without the ability to inject it for testing. Following the pattern seen in iam/oidc.go where HTTPClient is exposed as an injectable field, consider making the HTTP client injectable at the provider level to enable deterministic unit tests. This would allow tests to use httptest.NewServer to verify API interactions without requiring actual Kubernetes cluster access.

Copilot uses AI. Check for mistakes.

return client, server, token, nil
}
Comment on lines +122 to 255
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The new Kubernetes IAM provider functionality (API calls, in-cluster detection, TLS configuration) lacks test coverage. The existing tests in providers_test.go only validate configuration and basic stub behavior. Consider adding tests that verify the buildHTTPClient logic, in-cluster fallback, and serviceAccountExists behavior using httptest.Server.

Copilot uses AI. Check for mistakes.
4 changes: 2 additions & 2 deletions iam/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ func TestKubernetesProvider_ResolveIdentities_ServiceAccount(t *testing.T) {
if len(ids) != 1 {
t.Fatalf("expected 1 identity, got %d", len(ids))
}
if ids[0].Identifier != "sa:my-svc" {
t.Errorf("expected identifier 'sa:my-svc', got %s", ids[0].Identifier)
if ids[0].Identifier != "system:serviceaccount:default:my-svc" {
t.Errorf("expected identifier 'system:serviceaccount:default:my-svc', got %s", ids[0].Identifier)
}
}

Expand Down
Loading
Loading