diff --git a/go/core/cmd/controller/main.go b/go/core/cmd/controller/main.go index 576dee94a..f49a0a31f 100644 --- a/go/core/cmd/controller/main.go +++ b/go/core/cmd/controller/main.go @@ -29,9 +29,9 @@ import ( //nolint:gocyclo func main() { - authorizer := &auth.NoopAuthorizer{} app.Start(func(bootstrap app.BootstrapConfig) (*app.ExtensionConfig, error) { authenticator := getAuthenticator(bootstrap.Config.Auth) + authorizer := getAuthorizer(bootstrap.Config.Auth) return &app.ExtensionConfig{ Authenticator: authenticator, Authorizer: authorizer, @@ -51,3 +51,13 @@ func getAuthenticator(authCfg struct{ Mode, UserIDClaim string }) pkgauth.AuthPr panic("unknown auth mode: " + authCfg.Mode + " (valid modes: unsecure, trusted-proxy)") } } + +func getAuthorizer(authCfg struct{ Mode, UserIDClaim string }) pkgauth.Authorizer { + switch authCfg.Mode { + case "trusted-proxy": + // GroupAuthorizer with nil client — app.go will inject the kube client later + return auth.NewGroupAuthorizer(nil) + default: + return &auth.NoopAuthorizer{} + } +} diff --git a/go/core/internal/a2a/a2a_handler_mux.go b/go/core/internal/a2a/a2a_handler_mux.go index 3d4bb7722..823b27dbb 100644 --- a/go/core/internal/a2a/a2a_handler_mux.go +++ b/go/core/internal/a2a/a2a_handler_mux.go @@ -10,7 +10,8 @@ import ( authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" common "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" - "trpc.group/trpc-go/trpc-a2a-go/client" + "sigs.k8s.io/controller-runtime/pkg/client" + a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" "trpc.group/trpc-go/trpc-a2a-go/server" ) @@ -18,7 +19,7 @@ import ( type A2AHandlerMux interface { SetAgentHandler( agentRef string, - client *client.A2AClient, + client *a2aclient.A2AClient, card server.AgentCard, tracing server.Middleware, ) error @@ -34,22 +35,26 @@ type handlerMux struct { agentPathPrefix string sandboxPathPrefix string authenticator auth.AuthProvider + authorizer auth.Authorizer + kubeClient client.Client } var _ A2AHandlerMux = &handlerMux{} -func NewA2AHttpMux(agentPathPrefix, sandboxPathPrefix string, authenticator auth.AuthProvider) *handlerMux { +func NewA2AHttpMux(agentPathPrefix, sandboxPathPrefix string, authenticator auth.AuthProvider, authorizer auth.Authorizer, kubeClient client.Client) *handlerMux { return &handlerMux{ handlers: make(map[string]http.Handler), agentPathPrefix: agentPathPrefix, sandboxPathPrefix: sandboxPathPrefix, authenticator: authenticator, + authorizer: authorizer, + kubeClient: kubeClient, } } func (a *handlerMux) SetAgentHandler( agentRef string, - client *client.A2AClient, + client *a2aclient.A2AClient, card server.AgentCard, tracing server.Middleware, ) error { @@ -99,6 +104,21 @@ func (a *handlerMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Authorization check: verify user's groups can access this agent + if a.authorizer != nil { + session, sessionOk := auth.AuthSessionFrom(r.Context()) + if sessionOk { + resource := auth.Resource{ + Type: "Agent", + Name: agentNamespace + "/" + agentName, + } + if err := a.authorizer.Check(r.Context(), session.Principal(), auth.VerbGet, resource); err != nil { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + } + handlerName := routeKey(a.isSandboxRoute(r), agentNamespace, agentName) // get the underlying handler diff --git a/go/core/internal/httpserver/auth/group_authz.go b/go/core/internal/httpserver/auth/group_authz.go new file mode 100644 index 000000000..fa50a97a9 --- /dev/null +++ b/go/core/internal/httpserver/auth/group_authz.go @@ -0,0 +1,186 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // AllowedGroupsAnnotation is the annotation key on Agent CRs that specifies + // which groups can access the agent. Comma-separated list of group names. + // Rules: + // - No annotation or empty: agent is NOT visible to anyone + // - "public": agent is visible to all authenticated users + // - "doctors,nurses": agent is visible only to users in those groups + // Users in the "admin" group can see all agents regardless of annotation. + AllowedGroupsAnnotation = "kagent.dev/allowed-groups" + + // PublicGroup is the special group value that makes an agent visible to all. + PublicGroup = "public" + + // AdminGroup is the group that bypasses all access checks. + AdminGroup = "admin" +) + +// GroupAuthorizer implements auth.Authorizer by checking the user's JWT groups +// against the agent's allowed-groups annotation. +type GroupAuthorizer struct { + kubeClient client.Client +} + +var _ auth.Authorizer = (*GroupAuthorizer)(nil) + +// NewGroupAuthorizer creates a new GroupAuthorizer. +// kubeClient can be nil at creation time — call SetKubeClient before use. +func NewGroupAuthorizer(kubeClient client.Client) *GroupAuthorizer { + return &GroupAuthorizer{ + kubeClient: kubeClient, + } +} + +// SetKubeClient sets the kube client for the authorizer (used for late initialization). +func (a *GroupAuthorizer) SetKubeClient(c client.Client) { + a.kubeClient = c +} + +// Check verifies that the principal has access to the requested resource. +// For agent resources, it checks the allowed-groups annotation. +// For non-agent resources, access is always granted (backward compatible). +func (a *GroupAuthorizer) Check(ctx context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { + // Only enforce group checks on agent resources + if resource.Type != "Agent" { + return nil + } + + // If no resource name, this is a list operation — filtering happens in the handler + if resource.Name == "" { + return nil + } + + // Fail closed if kube client is not initialized + if a.kubeClient == nil { + return fmt.Errorf("access denied: authorizer not initialized") + } + + // Parse namespace/name from resource name + namespace, name, err := parseResourceRef(resource.Name) + if err != nil { + return fmt.Errorf("access denied: invalid resource reference") + } + + // Fetch the agent CR + agent := &v1alpha2.Agent{} + if err := a.kubeClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, + Name: name, + }, agent); err != nil { + if apierrors.IsNotFound(err) { + return nil // Agent not found — let the handler return 404 + } + // Fail closed on transient errors + return fmt.Errorf("access denied: unable to verify agent access") + } + + return checkAgentGroupAccess(principal, agent.GetAnnotations()) +} + +// FilterAgentsByGroup filters a list of agents to only those the principal can access. +// Used by list handlers to scope results by group. +func FilterAgentsByGroup(principal auth.Principal, agents []v1alpha2.AgentObject) []v1alpha2.AgentObject { + filtered := make([]v1alpha2.AgentObject, 0, len(agents)) + for _, agent := range agents { + if err := checkAgentGroupAccess(principal, agent.GetAnnotations()); err == nil { + filtered = append(filtered, agent) + } + } + return filtered +} + +// checkAgentGroupAccess checks if the principal's groups intersect with the agent's allowed groups. +// Rules: +// - Admin group bypasses all checks +// - No annotation or empty → denied (agent is private by default) +// - "public" in allowed groups → allowed for all authenticated users +// - Otherwise, user must have at least one matching group +func checkAgentGroupAccess(principal auth.Principal, annotations map[string]string) error { + userGroups := principal.Groups + + // Admin bypasses everything + if containsString(userGroups, AdminGroup) { + return nil + } + + allowedGroupsStr, ok := annotations[AllowedGroupsAnnotation] + if !ok || allowedGroupsStr == "" { + return fmt.Errorf("access denied") + } + + allowedGroups := parseCSV(allowedGroupsStr) + if len(allowedGroups) == 0 { + return fmt.Errorf("access denied") + } + + // "public" means visible to all authenticated users + if containsString(allowedGroups, PublicGroup) { + return nil + } + + if len(userGroups) == 0 { + return fmt.Errorf("access denied") + } + + if hasIntersection(userGroups, allowedGroups) { + return nil + } + + return fmt.Errorf("access denied") +} + +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +func parseCSV(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func hasIntersection(a, b []string) bool { + set := make(map[string]struct{}, len(b)) + for _, s := range b { + set[s] = struct{}{} + } + for _, s := range a { + if _, ok := set[s]; ok { + return true + } + } + return false +} + +func parseResourceRef(name string) (namespace, resourceName string, err error) { + parts := strings.SplitN(name, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid resource name: %s", name) + } + return parts[0], parts[1], nil +} diff --git a/go/core/internal/httpserver/auth/group_authz_test.go b/go/core/internal/httpserver/auth/group_authz_test.go new file mode 100644 index 000000000..d4044edb7 --- /dev/null +++ b/go/core/internal/httpserver/auth/group_authz_test.go @@ -0,0 +1,206 @@ +package auth + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +func TestCheckAgentGroupAccess_NoAnnotation_Denied(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"team-a"}, + } + err := checkAgentGroupAccess(principal, map[string]string{}) + if err == nil { + t.Error("expected denial when no annotation, got nil") + } +} + +func TestCheckAgentGroupAccess_EmptyAnnotation_Denied(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"team-a"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "", + }) + if err == nil { + t.Error("expected denial when empty annotation, got nil") + } +} + +func TestCheckAgentGroupAccess_PublicAnnotation_AllowsEveryone(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"random-group"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "public", + }) + if err != nil { + t.Errorf("expected nil for public agent, got %v", err) + } +} + +func TestCheckAgentGroupAccess_PublicWithOtherGroups(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"team-a"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "doctors,public,nurses", + }) + if err != nil { + t.Errorf("expected nil when public is in allowed groups, got %v", err) + } +} + +func TestCheckAgentGroupAccess_MatchingGroup(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"team-a", "team-b"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "team-b,team-c", + }) + if err != nil { + t.Errorf("expected nil for matching group, got %v", err) + } +} + +func TestCheckAgentGroupAccess_NoMatchingGroup(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + Groups: []string{"team-a"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "team-b,team-c", + }) + if err == nil { + t.Error("expected denial when no matching group, got nil") + } +} + +func TestCheckAgentGroupAccess_NoGroupsInJWT(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "user1"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "team-a", + }) + if err == nil { + t.Error("expected denial when user has no groups, got nil") + } +} + +func TestCheckAgentGroupAccess_AdminBypassesNoAnnotation(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "admin-user"}, + Groups: []string{"admin"}, + } + err := checkAgentGroupAccess(principal, map[string]string{}) + if err != nil { + t.Errorf("expected nil for admin user with no annotation, got %v", err) + } +} + +func TestCheckAgentGroupAccess_AdminBypassesRestrictedAgent(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "admin-user"}, + Groups: []string{"admin"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "doctors", + }) + if err != nil { + t.Errorf("expected nil for admin user on restricted agent, got %v", err) + } +} + +func TestCheckAgentGroupAccess_AdminWithOtherGroups(t *testing.T) { + principal := auth.Principal{ + User: auth.User{ID: "admin-user"}, + Groups: []string{"developers", "admin"}, + } + err := checkAgentGroupAccess(principal, map[string]string{ + AllowedGroupsAnnotation: "nurses", + }) + if err != nil { + t.Errorf("expected nil for admin user even without matching group, got %v", err) + } +} + +func TestExtractGroupsFromClaims(t *testing.T) { + tests := []struct { + name string + claims map[string]any + expected int + }{ + {"nil claims", nil, 0}, + {"missing claim", map[string]any{}, 0}, + {"[]any groups", map[string]any{"groups": []any{"a", "b"}}, 2}, + {"[]string groups", map[string]any{"groups": []string{"a", "b", "c"}}, 3}, + {"wrong type", map[string]any{"groups": "not-a-list"}, 0}, + {"cognito groups", map[string]any{"cognito:groups": []any{"x", "y"}}, 2}, + {"keycloak realm_access roles", map[string]any{"realm_access": map[string]any{"roles": []any{"role1", "role2"}}}, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractGroupsFromClaims(tt.claims) + if len(got) != tt.expected { + t.Errorf("extractGroupsFromClaims() = %v (len %d), want len %d", got, len(got), tt.expected) + } + }) + } +} + +func TestParseCSV(t *testing.T) { + tests := []struct { + input string + expected int + }{ + {"", 0}, + {"a", 1}, + {"a,b,c", 3}, + {" a , b , c ", 3}, + {"a,,b", 2}, + } + for _, tt := range tests { + got := parseCSV(tt.input) + if len(got) != tt.expected { + t.Errorf("parseCSV(%q) len = %d, want %d", tt.input, len(got), tt.expected) + } + } +} + +func TestContainsString(t *testing.T) { + if !containsString([]string{"a", "b", "c"}, "b") { + t.Error("expected true") + } + if containsString([]string{"a", "b"}, "c") { + t.Error("expected false") + } + if containsString(nil, "a") { + t.Error("expected false for nil slice") + } +} + +func TestHasIntersection(t *testing.T) { + tests := []struct { + a, b []string + expected bool + }{ + {[]string{"a"}, []string{"a"}, true}, + {[]string{"a"}, []string{"b"}, false}, + {[]string{"a", "b"}, []string{"b", "c"}, true}, + {nil, []string{"a"}, false}, + {[]string{"a"}, nil, false}, + } + for _, tt := range tests { + got := hasIntersection(tt.a, tt.b) + if got != tt.expected { + t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.expected) + } + } +} diff --git a/go/core/internal/httpserver/auth/proxy_authn.go b/go/core/internal/httpserver/auth/proxy_authn.go index d3bcd98fa..6115b13ef 100644 --- a/go/core/internal/httpserver/auth/proxy_authn.go +++ b/go/core/internal/httpserver/auth/proxy_authn.go @@ -51,6 +51,7 @@ func (a *ProxyAuthenticator) Authenticate(ctx context.Context, reqHeaders http.H P: auth.Principal{ User: auth.User{ID: userID}, Agent: auth.Agent{ID: agentID}, + Groups: extractGroupsFromClaims(rawClaims), Claims: rawClaims, }, authHeader: authHeader, @@ -111,3 +112,63 @@ func parseJWTPayload(tokenString string) (map[string]any, error) { return claims, nil } + +// extractGroupsFromClaims pulls the groups claim from JWT claims. +// Handles both []string and []interface{} formats from different OIDC providers. +// Supports nested claims via dot notation (e.g., "realm_access.roles" for Keycloak). +func extractGroupsFromClaims(claims map[string]any) []string { + // Try common claim names in order of priority + for _, claimName := range []string{"groups", "cognito:groups", "realm_access.roles"} { + if groups := extractClaimAsStringSlice(claims, claimName); len(groups) > 0 { + return groups + } + } + return nil +} + +// extractClaimAsStringSlice extracts a claim value as a string slice. +// Supports dot-delimited nested paths (e.g., "realm_access.roles"). +func extractClaimAsStringSlice(claims map[string]any, key string) []string { + raw, ok := resolveClaimValue(claims, key) + if !ok { + return nil + } + switch v := raw.(type) { + case []string: + return v + case []any: + groups := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + groups = append(groups, s) + } + } + return groups + } + return nil +} + +// resolveClaimValue resolves a claim from a map, supporting dot-delimited nested paths +// such as "realm_access.roles" in addition to top-level keys like "groups" or "cognito:groups". +func resolveClaimValue(claims map[string]any, key string) (any, bool) { + // Try direct lookup first (handles keys with colons like "cognito:groups") + if raw, ok := claims[key]; ok { + return raw, true + } + // Try dot-delimited nested path + if !strings.Contains(key, ".") { + return nil, false + } + current := any(claims) + for _, part := range strings.Split(key, ".") { + nextMap, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = nextMap[part] + if !ok { + return nil, false + } + } + return current, true +} diff --git a/go/core/internal/httpserver/handlers/agents.go b/go/core/internal/httpserver/handlers/agents.go index e002052d7..b2caa3936 100644 --- a/go/core/internal/httpserver/handlers/agents.go +++ b/go/core/internal/httpserver/handlers/agents.go @@ -9,6 +9,7 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" + authpkg "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -44,8 +45,20 @@ func (h *AgentsHandler) HandleListAgents(w ErrorResponseWriter, r *http.Request) return } + // Filter agents by group access only when group-based authorization is configured. + items := agentObjects(agentList.Items) + if _, ok := h.Authorizer.(*authpkg.GroupAuthorizer); ok { + principal, principalErr := GetPrincipal(r) + if principalErr == nil { + items = authpkg.FilterAgentsByGroup(principal, items) + } else { + log.Info("No principal found, returning empty agent list for security", "error", principalErr.Error()) + items = nil + } + } + agentsWithID := make([]api.AgentResponse, 0) - h.appendAgentResponses(r.Context(), log, agentObjects(agentList.Items), &agentsWithID) + h.appendAgentResponses(r.Context(), log, items, &agentsWithID) log.Info("Successfully listed agents", "count", len(agentsWithID)) data := api.NewResponse(agentsWithID, "Successfully listed agents", false) diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index eabd6ff05..52193deb3 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -47,6 +47,7 @@ import ( reconcilerutils "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler/utils" agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" "github.com/kagent-dev/kagent/go/core/internal/httpserver" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" common "github.com/kagent-dev/kagent/go/core/internal/utils" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) @@ -578,7 +579,11 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne } // Register A2A handlers on all replicas - a2aHandler := a2a.NewA2AHttpMux(httpserver.APIPathA2A, httpserver.APIPathA2ASandboxes, extensionCfg.Authenticator) + // Inject kube client into GroupAuthorizer if it's being used + if ga, ok := extensionCfg.Authorizer.(*authimpl.GroupAuthorizer); ok { + ga.SetKubeClient(mgr.GetClient()) + } + a2aHandler := a2a.NewA2AHttpMux(httpserver.APIPathA2A, httpserver.APIPathA2ASandboxes, extensionCfg.Authenticator, extensionCfg.Authorizer, mgr.GetClient()) if err := mgr.Add(a2a.NewA2ARegistrar( mgr.GetCache(), diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 6a6b793fd..ac360ddae 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -32,6 +32,7 @@ type Agent struct { type Principal struct { User User Agent Agent + Groups []string // Groups extracted from JWT (e.g. Keycloak groups claim) Claims map[string]any // Raw JWT claims (nil for non-JWT auth) }