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
12 changes: 11 additions & 1 deletion go/core/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Comment on lines +57 to +60
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

AUTH_GROUPS_CLAIM is read and passed into NewGroupAuthorizer, but GroupAuthorizer doesn’t use it for JWT parsing, so this configuration currently has no effect. If the intent is to override the JWT groups claim, the value likely needs to be applied in ProxyAuthenticator’s group extraction instead.

Copilot uses AI. Check for mistakes.
return &auth.NoopAuthorizer{}
}
}
28 changes: 24 additions & 4 deletions go/core/internal/a2a/a2a_handler_mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ 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"
)

// A2AHandlerMux is an interface that defines methods for adding, getting, and removing agentic task handlers.
type A2AHandlerMux interface {
SetAgentHandler(
agentRef string,
client *client.A2AClient,
client *a2aclient.A2AClient,
card server.AgentCard,
tracing server.Middleware,
) error
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Comment on lines +115 to +117
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

The 403 response includes err.Error() from the authorizer, which currently embeds details like the agent’s allowed groups and the user’s groups. Consider returning a generic forbidden message to clients and logging the detailed reason server-side to avoid leaking group/annotation information.

Copilot uses AI. Check for mistakes.
}
}
}

handlerName := routeKey(a.isSandboxRoute(r), agentNamespace, agentName)

// get the underlying handler
Expand Down
186 changes: 186 additions & 0 deletions go/core/internal/httpserver/auth/group_authz.go
Original file line number Diff line number Diff line change
@@ -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")
}
Comment on lines +78 to +89
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Check calls a.kubeClient.Get(...) without guarding against a.kubeClient being nil (possible given the late injection pattern). Also, returning nil on any Get error fails open and could incorrectly allow access during transient API errors. Prefer failing closed: return a non-nil error if kubeClient is nil, and only treat NotFound as a special case if you intentionally want handlers to return 404.

Copilot uses AI. Check for mistakes.

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
}
Loading