diff --git a/components/backend/handlers/middleware.go b/components/backend/handlers/middleware.go index d810c175b..f4c24165a 100644 --- a/components/backend/handlers/middleware.go +++ b/components/backend/handlers/middleware.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "ambient-code-backend/server" + "github.com/gin-gonic/gin" authv1 "k8s.io/api/authorization/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -261,61 +263,10 @@ func updateAccessKeyLastUsedAnnotation(c *gin.Context) { } } -// ExtractServiceAccountFromAuth extracts namespace and ServiceAccount name from the Authorization Bearer JWT 'sub' claim -// Also checks X-Remote-User header for service account format (OpenShift OAuth proxy format) -// Returns (namespace, saName, true) when a SA subject is present, otherwise ("","",false) +// ExtractServiceAccountFromAuth delegates to server.ExtractServiceAccountFromAuth. +// Kept as a forwarding function for backward compatibility with callers in this package. func ExtractServiceAccountFromAuth(c *gin.Context) (string, string, bool) { - // Check X-Remote-User header (OpenShift OAuth proxy format) - // This is a production feature, not just for tests - remoteUser := c.GetHeader("X-Remote-User") - if remoteUser != "" { - const prefix = "system:serviceaccount:" - if strings.HasPrefix(remoteUser, prefix) { - rest := strings.TrimPrefix(remoteUser, prefix) - parts := strings.SplitN(rest, ":", 2) - if len(parts) == 2 { - return parts[0], parts[1], true - } - } - } - - // Standard Authorization Bearer JWT parsing - rawAuth := c.GetHeader("Authorization") - parts := strings.SplitN(rawAuth, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - return "", "", false - } - token := strings.TrimSpace(parts[1]) - if token == "" { - return "", "", false - } - segs := strings.Split(token, ".") - if len(segs) < 2 { - return "", "", false - } - payloadB64 := segs[1] - if m := len(payloadB64) % 4; m != 0 { - payloadB64 += strings.Repeat("=", 4-m) - } - data, err := base64.URLEncoding.DecodeString(payloadB64) - if err != nil { - return "", "", false - } - var payload map[string]interface{} - if err := json.Unmarshal(data, &payload); err != nil { - return "", "", false - } - sub, _ := payload["sub"].(string) - const prefix = "system:serviceaccount:" - if !strings.HasPrefix(sub, prefix) { - return "", "", false - } - rest := strings.TrimPrefix(sub, prefix) - parts2 := strings.SplitN(rest, ":", 2) - if len(parts2) != 2 { - return "", "", false - } - return parts2[0], parts2[1], true + return server.ExtractServiceAccountFromAuth(c) } // ValidateProjectContext is middleware for project context validation diff --git a/components/backend/handlers/permissions.go b/components/backend/handlers/permissions.go old mode 100644 new mode 100755 index 44cd404ce..38c63005a --- a/components/backend/handlers/permissions.go +++ b/components/backend/handlers/permissions.go @@ -381,6 +381,13 @@ func CreateProjectKey(c *gin.Context) { return } + // Require authenticated user identity so integration access works + creatorUserID := c.GetString("userID") + if creatorUserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "User identity required to create access keys"}) + return + } + // Create a dedicated ServiceAccount per key ts := time.Now().Unix() saName := fmt.Sprintf("ambient-key-%s-%d", sanitizeName(req.Name), ts) @@ -390,10 +397,11 @@ func CreateProjectKey(c *gin.Context) { Namespace: projectName, Labels: map[string]string{"app": "ambient-access-key"}, Annotations: map[string]string{ - "ambient-code.io/key-name": req.Name, - "ambient-code.io/description": req.Description, - "ambient-code.io/created-at": time.Now().Format(time.RFC3339), - "ambient-code.io/role": role, + "ambient-code.io/key-name": req.Name, + "ambient-code.io/description": req.Description, + "ambient-code.io/created-at": time.Now().Format(time.RFC3339), + "ambient-code.io/role": role, + "ambient-code.io/created-by-user-id": creatorUserID, }, }, } diff --git a/components/backend/server/server.go b/components/backend/server/server.go old mode 100644 new mode 100755 index 7739118fc..1bb440e5a --- a/components/backend/server/server.go +++ b/components/backend/server/server.go @@ -9,6 +9,8 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + authnv1 "k8s.io/api/authentication/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // RouterFunc is a function that can register routes on a Gin router @@ -158,6 +160,72 @@ func forwardedIdentityMiddleware() gin.HandlerFunc { if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { c.Set("forwardedAccessToken", v) } + + // Fallback: if userID is still empty, verify the Bearer token via + // TokenReview to securely resolve the ServiceAccount identity, then + // read the created-by-user-id annotation. This enables API key- + // authenticated requests to inherit the creating user's identity + // so that integration credentials (GitHub, Jira, GitLab) are accessible. + if c.GetString("userID") == "" && K8sClient != nil { + if ns, saName, ok := resolveServiceAccountFromToken(c); ok { + sa, err := K8sClient.CoreV1().ServiceAccounts(ns).Get(c.Request.Context(), saName, v1.GetOptions{}) + if err == nil && sa.Annotations != nil { + if uid := sa.Annotations["ambient-code.io/created-by-user-id"]; uid != "" { + c.Set("userID", uid) + } + } + } + } + c.Next() } } + +// resolveServiceAccountFromToken verifies the Bearer token via K8s TokenReview +// and extracts the ServiceAccount namespace and name from the authenticated identity. +// Returns (namespace, saName, true) when verified, otherwise ("","",false). +func resolveServiceAccountFromToken(c *gin.Context) (string, string, bool) { + rawAuth := c.GetHeader("Authorization") + parts := strings.SplitN(rawAuth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", "", false + } + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", "", false + } + + tr := &authnv1.TokenReview{Spec: authnv1.TokenReviewSpec{Token: token}} + rv, err := K8sClient.AuthenticationV1().TokenReviews().Create(c.Request.Context(), tr, v1.CreateOptions{}) + if err != nil || !rv.Status.Authenticated || rv.Status.Error != "" { + return "", "", false + } + + subj := strings.TrimSpace(rv.Status.User.Username) + const prefix = "system:serviceaccount:" + if !strings.HasPrefix(subj, prefix) { + return "", "", false + } + rest := strings.TrimPrefix(subj, prefix) + segs := strings.SplitN(rest, ":", 2) + if len(segs) != 2 { + return "", "", false + } + return segs[0], segs[1], true +} + +// ExtractServiceAccountFromAuth extracts namespace and ServiceAccount name +// from the X-Remote-User header (OpenShift OAuth proxy format). +// Returns (namespace, saName, true) when a SA subject is present, otherwise ("","",false). +func ExtractServiceAccountFromAuth(c *gin.Context) (string, string, bool) { + if remoteUser := c.GetHeader("X-Remote-User"); remoteUser != "" { + const prefix = "system:serviceaccount:" + if strings.HasPrefix(remoteUser, prefix) { + parts := strings.SplitN(strings.TrimPrefix(remoteUser, prefix), ":", 2) + if len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + return "", "", false +}