Skip to content

feat: group-based agent authorization via OIDC groups claim#1766

Open
bvaturi wants to merge 1 commit intokagent-dev:mainfrom
bvaturi:feat/group-based-authz
Open

feat: group-based agent authorization via OIDC groups claim#1766
bvaturi wants to merge 1 commit intokagent-dev:mainfrom
bvaturi:feat/group-based-authz

Conversation

@bvaturi
Copy link
Copy Markdown

@bvaturi bvaturi commented Apr 28, 2026

feat: Group-based agent authorization via OIDC groups claim

Summary

Adds annotation-driven, group-based access control for agents. When the controller runs in trusted-proxy mode, the GroupAuthorizer checks the user's JWT groups claim against the kagent.dev/allowed-groups annotation on Agent CRs.

This enables multi-tenant agent visibility without requiring separate namespaces — teams can share a namespace while controlling which agents each group can see and invoke.

Access Rules

Annotation Behavior
No annotation Agent is hidden from all users (except admins)
kagent.dev/allowed-groups: public Agent is visible to all authenticated users
kagent.dev/allowed-groups: doctors,nurses Agent is visible only to users in those groups
User in admin group Sees all agents regardless of annotation

How it works

  1. The ProxyAuthenticator extracts the groups claim from the JWT and populates Principal.Groups
  2. The GroupAuthorizer (replacing NoopAuthorizer in trusted-proxy mode) checks the annotation on each agent
  3. The agent list handler filters results by group membership
  4. The A2A handler mux checks group access before forwarding requests to agent handlers

OIDC Provider Compatibility

Works with any OIDC provider that includes groups in the JWT:

  • Keycloak (Group Membership mapper)
  • Okta (Groups claim)
  • Azure AD / Entra (groups claim)
  • AWS Cognito (cognito:groups)
  • Auth0 (custom action)

The groups claim name defaults to groups but can be overridden via AUTH_GROUPS_CLAIM env var.

Changes

  • go/core/pkg/auth/auth.go — Add Groups []string to Principal
  • go/core/internal/httpserver/auth/proxy_authn.go — Extract groups from JWT claims (supports groups, cognito:groups, realm_access.roles)
  • go/core/internal/httpserver/auth/group_authz.go — New GroupAuthorizer implementation (196 lines)
  • go/core/internal/httpserver/auth/group_authz_test.go — 14 unit tests (205 lines)
  • go/core/internal/httpserver/handlers/agents.go — Filter agent list by group
  • go/core/internal/a2a/a2a_handler_mux.go — Authz check on A2A requests
  • go/core/pkg/app/app.go — Inject kube client into GroupAuthorizer
  • go/core/cmd/controller/main.go — Use GroupAuthorizer when auth mode is trusted-proxy

Backward Compatibility

  • In unsecure mode: NoopAuthorizer is used (no change)
  • In trusted-proxy mode without annotations: all agents are hidden (secure by default)
  • Existing agents need kagent.dev/allowed-groups: public annotation to remain visible to all users

Example

apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
  name: my-agent
  namespace: kagent
  annotations:
    kagent.dev/allowed-groups: "team-a,team-b"
spec:
  type: Declarative
  # ...

Testing

  • 14 unit tests covering all access rules
  • Tested end-to-end with Keycloak OIDC (3 users, 3 groups, 12 agents)

Copilot AI review requested due to automatic review settings April 28, 2026 12:04
@github-actions github-actions Bot added the enhancement New feature or request label Apr 28, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds group-based authorization for Agent visibility/invocation when running in trusted-proxy auth mode by extracting JWT group claims, enforcing an kagent.dev/allowed-groups annotation, filtering agent lists, and gating A2A requests.

Changes:

  • Extend auth.Principal with Groups and populate it from JWT claims in ProxyAuthenticator
  • Add GroupAuthorizer and unit tests; wire it in for trusted-proxy mode
  • Enforce group access in the agents list handler and A2A handler mux

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
go/core/pkg/auth/auth.go Adds Principal.Groups to carry group membership derived from JWTs
go/core/internal/httpserver/auth/proxy_authn.go Extracts groups from JWT claims and stores them in Principal
go/core/internal/httpserver/auth/group_authz.go New authorizer enforcing kagent.dev/allowed-groups on Agent CRs + list filtering helper
go/core/internal/httpserver/auth/group_authz_test.go Unit tests for group access rules and parsing helpers
go/core/internal/httpserver/handlers/agents.go Filters agent list results based on group access
go/core/internal/a2a/a2a_handler_mux.go Adds per-request authz check before proxying to an agent handler
go/core/pkg/app/app.go Injects controller-runtime kube client into GroupAuthorizer and passes authorizer into A2A mux
go/core/cmd/controller/main.go Selects GroupAuthorizer in trusted-proxy mode and reads AUTH_GROUPS_CLAIM

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +115 to +117
if err := a.authorizer.Check(r.Context(), session.Principal(), auth.VerbGet, resource); err != nil {
http.Error(w, "Forbidden: "+err.Error(), http.StatusForbidden)
return
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.
Comment on lines +59 to +64
case "trusted-proxy":
// GroupAuthorizer with nil client — app.go will inject the kube client later
// Groups claim defaults to "groups" but can be overridden via AUTH_GROUPS_CLAIM env var
groupsClaim := os.Getenv("AUTH_GROUPS_CLAIM")
return auth.NewGroupAuthorizer(nil, groupsClaim)
default:
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.
{"[]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},
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.

TestExtractGroupsFromClaims covers groups and cognito:groups, but doesn’t include a case for Keycloak’s common nested structure { "realm_access": { "roles": [...] } }. Adding that test would prevent regressions in the realm_access parsing support mentioned in the PR description.

Suggested change
{"cognito groups", map[string]any{"cognito:groups": []any{"x", "y"}}, 2},
{"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},

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +55
// Filter agents by group access
items := agentObjects(agentList.Items)
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
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 new group filtering runs unconditionally, which changes behavior in unsecure mode: UnsecureAuthenticator never populates Principal.Groups, so FilterAgentsByGroup will deny any agent without a kagent.dev/allowed-groups annotation (breaking the stated backward compatibility). Consider applying this filtering only when the configured Authorizer is a *auth.GroupAuthorizer (or when running in trusted-proxy mode), and keep the previous behavior for unsecure mode.

Suggested change
// Filter agents by group access
items := agentObjects(agentList.Items)
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
// Filter agents by group access only when group-based authorization is configured.
items := agentObjects(agentList.Items)
if _, ok := h.Authorizer.(*auth.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
}

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +130
// extractClaimAsStringSlice extracts a claim value as a string slice.
func extractClaimAsStringSlice(claims map[string]any, key string) []string {
raw, ok := claims[key]
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.

extractGroupsFromClaims attempts to read Keycloak roles from a flat key realm_access.roles, but Keycloak typically encodes this as a nested object { "realm_access": { "roles": [...] } }. As written, this claim will never be found. Update the extraction logic to handle nested maps (and add/adjust tests accordingly).

Suggested change
// extractClaimAsStringSlice extracts a claim value as a string slice.
func extractClaimAsStringSlice(claims map[string]any, key string) []string {
raw, ok := claims[key]
// resolveClaimValue resolves a claim from a map, supporting dot-delimited nested paths
// such as "realm_access.roles" in addition to top-level keys.
func resolveClaimValue(claims map[string]any, key string) (any, bool) {
if raw, ok := claims[key]; ok {
return raw, true
}
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
}
// extractClaimAsStringSlice extracts a claim value as a string slice.
func extractClaimAsStringSlice(claims map[string]any, key string) []string {
raw, ok := resolveClaimValue(claims, key)

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +61
type GroupAuthorizer struct {
kubeClient client.Client
groupsClaim string
}

var _ auth.Authorizer = (*GroupAuthorizer)(nil)

// NewGroupAuthorizer creates a new GroupAuthorizer.
// kubeClient can be nil at creation time — call SetKubeClient before use.
// groupsClaim is the JWT claim name containing user groups (default: "groups").
func NewGroupAuthorizer(kubeClient client.Client, groupsClaim string) *GroupAuthorizer {
if groupsClaim == "" {
groupsClaim = "groups"
}
return &GroupAuthorizer{
kubeClient: kubeClient,
groupsClaim: groupsClaim,
}
}

// SetKubeClient sets the kube client for the authorizer (used for late initialization).
func (a *GroupAuthorizer) SetKubeClient(c client.Client) {
a.kubeClient = c
}

// GroupsClaim returns the configured JWT claim name for groups.
func (a *GroupAuthorizer) GroupsClaim() string {
return a.groupsClaim
}
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.

GroupAuthorizer stores groupsClaim and exposes GroupsClaim(), but this value is never used to extract groups from JWTs (group parsing is currently hardcoded in ProxyAuthenticator). This makes AUTH_GROUPS_CLAIM effectively a no-op. Either plumb the configured claim name into the authenticator’s extraction logic or remove this field/API to avoid misleading configuration.

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +90
// Fetch the agent CR
agent := &v1alpha2.Agent{}
if err := a.kubeClient.Get(ctx, types.NamespacedName{
Namespace: namespace,
Name: name,
}, agent); err != nil {
return nil // Agent not found — let the handler return 404
}
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.
@bvaturi bvaturi force-pushed the feat/group-based-authz branch from 3143d5d to 6f367a1 Compare April 28, 2026 12:48
- Fail closed on transient API errors and nil kubeClient
- Generic 403 response to avoid leaking group/annotation info
- Support nested JWT claims (e.g., realm_access.roles for Keycloak)
- Only filter agents when GroupAuthorizer is configured (backward compat for unsecure mode)
- Remove unused groupsClaim field (groups extraction is automatic)
- 15 unit tests covering all access rules
@bvaturi bvaturi force-pushed the feat/group-based-authz branch from 6f367a1 to bbcdbde Compare April 28, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants