diff --git a/docs/architecture/opa-authorization.md b/docs/architecture/opa-authorization.md new file mode 100644 index 000000000..3683ed8b9 --- /dev/null +++ b/docs/architecture/opa-authorization.md @@ -0,0 +1,326 @@ +# External Authorization Guide + +This document explains kagent's authorization architecture and how to integrate an external policy engine using the **provider adapter layer**. + +## Overview + +Kagent uses an `Authorizer` interface to decouple authorization decisions from HTTP handlers. The `ExternalAuthorizer` sends requests to an external policy engine and delegates **wire format translation** to a pluggable `Provider` adapter. Each provider knows how to marshal kagent's `AuthzRequest` into the engine's expected format and unmarshal the engine's response back into an `AuthzDecision`. + +```text +HTTP Request + │ + ▼ +AuthnMiddleware ──▶ session.Claims() + │ + ▼ +Handler.Check() + │ + ▼ +ExternalAuthorizer.Check() + │ + ├── Provider.MarshalRequest(AuthzRequest) → engine-specific JSON + │ + ▼ +HTTP POST to endpoint + │ + ├── Provider.UnmarshalDecision(response) → AuthzDecision + ▼ +AuthzDecision +``` + +When no external endpoint is configured, kagent falls back to the `NoopAuthorizer` which allows all requests. + +## Provider Architecture + +The **Provider** interface translates between kagent's internal types and engine-specific wire formats: + +```go +type Provider interface { + Name() string + MarshalRequest(req auth.AuthzRequest) ([]byte, error) + UnmarshalDecision(data []byte) (*auth.AuthzDecision, error) +} +``` + +### Built-in Providers + +| Provider | Wire Format | When to Use | +|----------|-------------|-------------| +| **OPA** (default) | Request: `{"input": }`, Response: `{"result": }` | OPA's `/v1/data/` REST API | + +### How Providers Work + +The `ExternalAuthorizer` owns the HTTP transport (POST, status code checks, timeouts). The `Provider` owns the serialization: + +1. `Provider.MarshalRequest()` wraps `AuthzRequest` into the engine's expected format +2. `ExternalAuthorizer` sends the HTTP POST +3. `Provider.UnmarshalDecision()` extracts `AuthzDecision` from the engine's response format + +This separation means adding a new engine requires only a new `Provider` implementation — no changes to the HTTP transport layer. + +## OPA Provider + +The OPA provider is the default. It wraps requests for OPA's `/v1/data/` REST API. + +### Request Format + +OPA expects input wrapped in an `input` key: + +```http +POST /v1/data/kagent/authz HTTP/1.1 +Content-Type: application/json + +{ + "input": { + "claims": { + "sub": "user-123", + "email": "alice@example.com", + "groups": ["platform-team"] + }, + "resource": { + "type": "Agent", + "name": "default/my-agent" + }, + "action": "get" + } +} +``` + +### Response Format + +OPA wraps policy output in a `result` key: + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "result": { + "allowed": true, + "reason": "" + } +} +``` + +### Field Reference + +| Field | Type | Description | +|---|---|---| +| `input.claims` | `object \| null` | Session claims from the authentication layer (JWT claims, OIDC claims, etc.). `null` when no claims are available. | +| `input.resource.type` | `string` | Kubernetes resource kind (e.g. `Agent`, `Session`, `ModelConfig`) | +| `input.resource.name` | `string` | Resource identifier, typically `namespace/name` | +| `input.action` | `string` | One of: `get`, `create`, `update`, `delete` | +| `result.allowed` | `bool` | Whether the request is permitted | +| `result.reason` | `string` | Human-readable explanation (especially useful for denials) | + +### Rego Policy Example + +```rego +package kagent.authz + +import rego.v1 + +default allowed := false +default reason := "no matching rule" + +# OPA receives kagent's AuthzRequest inside `input`. +# The policy evaluates input.claims, input.resource, and input.action. + +# Allow platform-team members full access. +allowed if { + "platform-team" in input.claims.groups +} + +reason := "" if { allowed } + +# Allow agent-viewers read-only access to agents. +allowed if { + "agent-viewers" in input.claims.groups + input.resource.type == "Agent" + input.action == "get" +} + +# Deny deletion unless admin. +reason := "only admins can delete resources" if { + input.action == "delete" + not "admin" in input.claims.groups +} +``` + +### Error Handling + +- **HTTP 200 with valid JSON** — Normal decision flow +- **Non-200 status** — Treated as a system error (not a denial) +- **Network error / timeout** — Treated as a system error + +System errors are returned to the `Check()` helper, which implements **fail-open** semantics: the request is allowed and the error is logged. + +## Configuration + +### Environment Variables + +```bash +# Required: URL of the authorization endpoint +EXTERNAL_AUTHZ_ENDPOINT=http://opa:8181/v1/data/kagent/authz + +# Optional: provider type (defaults to "opa") +AUTHZ_PROVIDER=opa +``` + +When `EXTERNAL_AUTHZ_ENDPOINT` is empty or unset, kagent uses the `NoopAuthorizer` (all requests allowed). `AUTHZ_PROVIDER` is only used when an endpoint is set. + +### CLI Flags + +```bash +--external-authz-endpoint=http://opa:8181/v1/data/kagent/authz +--authz-provider=opa +``` + +### Helm Values + +```yaml +controller: + authorization: + provider: "opa" # or "" (defaults to opa) + externalEndpoint: "http://opa:8181/v1/data/kagent/authz" +``` + +This sets the `EXTERNAL_AUTHZ_ENDPOINT` and `AUTHZ_PROVIDER` environment variables in the controller configmap. + +## Adding a New Provider + +To add support for a new policy engine (e.g. Cerbos): + +1. **Create the provider file**: `go/internal/httpserver/auth/provider_cerbos.go` + + ```go + package auth + + type CerbosProvider struct{} + + func (p *CerbosProvider) Name() string { return "cerbos" } + + func (p *CerbosProvider) MarshalRequest(req auth.AuthzRequest) ([]byte, error) { + // Translate AuthzRequest to Cerbos CheckResourcesRequest format + } + + func (p *CerbosProvider) UnmarshalDecision(data []byte) (*auth.AuthzDecision, error) { + // Translate Cerbos CheckResourcesResponse to AuthzDecision + } + ``` + +2. **Register in the factory** (`provider.go`): + + ```go + func ProviderByName(name string) (Provider, error) { + switch name { + case "opa", "": + return &OPAProvider{}, nil + case "cerbos": + return &CerbosProvider{}, nil + default: + return nil, fmt.Errorf("unknown authz provider: %q (supported: opa, cerbos)", name) + } + } + ``` + +3. **Add tests**: `go/internal/httpserver/auth/provider_test.go` — add table entries for the new provider's marshal/unmarshal behavior. + +4. **Update docs**: Add the new provider to the table in this document. + +## Current Authorization Architecture + +### The `Authorizer` interface + +All authorization flows through one interface defined in `go/pkg/auth/auth.go`: + +```go +type Authorizer interface { + Check(ctx context.Context, req AuthzRequest) (*AuthzDecision, error) +} +``` + +### The `Check()` helper + +Handlers never call the `Authorizer` directly. They use a helper in `go/internal/httpserver/handlers/helpers.go` that: + +1. Maps the HTTP method to an `auth.Verb` (`GET` → `get`, `POST` → `create`, etc.) +2. Extracts `Claims` from the request context (set by the authentication middleware) +3. Calls `authorizer.Check()` +4. Implements **fail-open** semantics: if `Check()` returns an error, the request is allowed and the error is logged + +Every handler follows the same pattern: + +```go +if err := Check(h.Authorizer, r, auth.Resource{Type: "Agent"}); err != nil { + w.RespondWithError(err) + return +} +``` + +### The `UnsecureAuthenticator` + +The current authenticator builds a session with **no claims**. A real JWT/OIDC authenticator would populate the claims map, which flows automatically through the pipeline: + +``` +AuthnMiddleware → context → Check() helper → AuthzRequest.Claims → ExternalAuthorizer +``` + +## Fail-Open vs Fail-Closed + +### Current behavior (fail-open) + +When `authorizer.Check()` returns an error (e.g. the external endpoint is unreachable), the `Check()` helper logs the error and **allows the request**: + +```go +if err != nil { + log.Error(err, "authorization check failed, allowing access (fail-open)") + return nil +} +``` + +### Switching to fail-closed + +For production, change the error handling in `Check()` to deny on error: + +```go +if err != nil { + log.Error(err, "authorization check failed, denying access (fail-closed)") + return errors.NewServiceUnavailableError( + "authorization service unavailable", + fmt.Errorf("authz check failed: %w", err), + ) +} +``` + +### Recommended rollout strategy + +1. **Audit mode** — Deploy with fail-open. Log decisions but never block. Monitor for unexpected denials. +2. **Shadow mode** — Run the external authorizer alongside `NoopAuthorizer`. Compare results. +3. **Enforce mode** — Switch to fail-closed once policy coverage is validated. + +## What Does NOT Change + +| Component | Why it stays the same | +|---|---| +| `handlers/*.go` call sites | They call `Check(h.Authorizer, r, resource)` — the authorizer is injected | +| `Check()` helper in `handlers/helpers.go` | It maps HTTP methods, extracts claims, and delegates — all generic | +| `AuthzRequest` / `AuthzDecision` types | They carry claims, resource, action, and allowed/reason | +| `Session` interface | It already exposes `Claims() map[string]any` | +| `AuthnMiddleware` | It stores the session in context regardless of authenticator implementation | +| `ExtensionConfig` / `ServerConfig` | They accept `auth.Authorizer` (interface), not a concrete type | + +## Related Files + +- [go/pkg/auth/auth.go](../../go/pkg/auth/auth.go) — `Authorizer` interface, `AuthzRequest`, `AuthzDecision`, `Session` interface, `AuthnMiddleware` +- [go/internal/httpserver/auth/authz.go](../../go/internal/httpserver/auth/authz.go) — `NoopAuthorizer` implementation +- [go/internal/httpserver/auth/external_authz.go](../../go/internal/httpserver/auth/external_authz.go) — `ExternalAuthorizer` implementation +- [go/internal/httpserver/auth/provider.go](../../go/internal/httpserver/auth/provider.go) — `Provider` interface and `ProviderByName` factory +- [go/internal/httpserver/auth/provider_opa.go](../../go/internal/httpserver/auth/provider_opa.go) — OPA provider implementation +- [go/internal/httpserver/auth/authn.go](../../go/internal/httpserver/auth/authn.go) — `UnsecureAuthenticator`, `SimpleSession`, `A2AAuthenticator` +- [go/internal/httpserver/handlers/helpers.go](../../go/internal/httpserver/handlers/helpers.go) — `Check()` helper with fail-open logic +- [go/cmd/controller/main.go](../../go/cmd/controller/main.go) — Authorizer wiring point +- [go/pkg/app/app.go](../../go/pkg/app/app.go) — `ExtensionConfig` that carries the `Authorizer` to the HTTP server +- [go/pkg/auth/external_authz_test.go](../../go/pkg/auth/external_authz_test.go) — Tests for the `Authorizer` interface contract +- [go/internal/httpserver/auth/external_authz_test.go](../../go/internal/httpserver/auth/external_authz_test.go) — Tests for the `ExternalAuthorizer` implementation +- [go/internal/httpserver/auth/provider_test.go](../../go/internal/httpserver/auth/provider_test.go) — Tests for provider implementations diff --git a/go/cmd/controller/main.go b/go/cmd/controller/main.go index 2049e6962..e739bc7e0 100644 --- a/go/cmd/controller/main.go +++ b/go/cmd/controller/main.go @@ -17,24 +17,37 @@ limitations under the License. package main import ( + "fmt" + "net/http" + "os" + "time" + "github.com/kagent-dev/kagent/go/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/pkg/app" - - // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) - // to ensure that exec-entrypoint and run can make use of them. - _ "k8s.io/client-go/plugin/pkg/client/auth" + pkgauth "github.com/kagent-dev/kagent/go/pkg/auth" ) -//nolint:gocyclo func main() { - authorizer := &auth.NoopAuthorizer{} authenticator := &auth.UnsecureAuthenticator{} app.Start(func(bootstrap app.BootstrapConfig) (*app.ExtensionConfig, error) { + var authorizer pkgauth.Authorizer + if endpoint := os.Getenv("EXTERNAL_AUTHZ_ENDPOINT"); endpoint != "" { + provider, err := auth.ProviderByName(os.Getenv("AUTHZ_PROVIDER")) + if err != nil { + return nil, fmt.Errorf("invalid authz provider: %w", err) + } + authorizer = &auth.ExternalAuthorizer{ + Endpoint: endpoint, + Provider: provider, + Client: &http.Client{Timeout: 5 * time.Second}, + } + } else { + authorizer = &auth.NoopAuthorizer{} + } + return &app.ExtensionConfig{ - Authenticator: authenticator, - Authorizer: authorizer, - AgentPlugins: nil, - MCPServerPlugins: nil, + Authenticator: authenticator, + Authorizer: authorizer, }, nil }) } diff --git a/go/controller b/go/controller new file mode 100755 index 000000000..63c07a1ac Binary files /dev/null and b/go/controller differ diff --git a/go/internal/httpserver/auth/authn.go b/go/internal/httpserver/auth/authn.go index ac5ab641a..c9bc2dd2a 100644 --- a/go/internal/httpserver/auth/authn.go +++ b/go/internal/httpserver/auth/authn.go @@ -11,12 +11,21 @@ import ( ) type SimpleSession struct { - P auth.Principal + principal auth.Principal authHeader string + claims map[string]any +} + +func NewSimpleSession(principal auth.Principal, claims map[string]any) *SimpleSession { + return &SimpleSession{principal: principal, claims: claims} } func (s *SimpleSession) Principal() auth.Principal { - return s.P + return s.principal +} + +func (s *SimpleSession) Claims() map[string]any { + return s.claims } type UnsecureAuthenticator struct{} @@ -29,20 +38,18 @@ func (a *UnsecureAuthenticator) Authenticate(ctx context.Context, reqHeaders htt if userID == "" { userID = "admin@kagent.dev" } - agentId := reqHeaders.Get("X-Agent-Name") - authHeader := reqHeaders.Get("Authorization") + agentID := reqHeaders.Get("X-Agent-Name") - return &SimpleSession{ - P: auth.Principal{ - User: auth.User{ - ID: userID, - }, - Agent: auth.Agent{ - ID: agentId, - }, + session := NewSimpleSession( + auth.Principal{ + User: auth.User{ID: userID}, + Agent: auth.Agent{ID: agentID}, }, - authHeader: authHeader, - }, nil + nil, + ) + session.authHeader = reqHeaders.Get("Authorization") + + return session, nil } func (a *UnsecureAuthenticator) UpstreamAuth(r *http.Request, session auth.Session, upstreamPrincipal auth.Principal) error { diff --git a/go/internal/httpserver/auth/authn_test.go b/go/internal/httpserver/auth/authn_test.go index e598901d3..fc97ba4f4 100644 --- a/go/internal/httpserver/auth/authn_test.go +++ b/go/internal/httpserver/auth/authn_test.go @@ -49,9 +49,14 @@ func TestAuthnMiddleware(t *testing.T) { if session == nil || session.Principal().User.ID != tt.expectedUser { t.Fatalf("Expected user %s but got %v", tt.expectedUser, session) } + // Claims() should be nil for default SimpleSession (no JWT context) + if session.Claims() != nil { + t.Fatalf("Expected nil claims for default session but got %v", session.Claims()) + } } else if session != nil { t.Fatalf("Expected no session but got %v", session) } }) } } + diff --git a/go/internal/httpserver/auth/authz.go b/go/internal/httpserver/auth/authz.go index e23c696a8..5421ff160 100644 --- a/go/internal/httpserver/auth/authz.go +++ b/go/internal/httpserver/auth/authz.go @@ -8,8 +8,8 @@ import ( type NoopAuthorizer struct{} -func (a *NoopAuthorizer) Check(ctx context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { - return nil +func (a *NoopAuthorizer) Check(ctx context.Context, req auth.AuthzRequest) (*auth.AuthzDecision, error) { + return &auth.AuthzDecision{Allowed: true}, nil } var _ auth.Authorizer = (*NoopAuthorizer)(nil) diff --git a/go/internal/httpserver/auth/external_authz.go b/go/internal/httpserver/auth/external_authz.go new file mode 100644 index 000000000..8d7ded7e9 --- /dev/null +++ b/go/internal/httpserver/auth/external_authz.go @@ -0,0 +1,57 @@ +package auth + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +// ExternalAuthorizer calls an external HTTP authorization endpoint to make +// authorization decisions. It delegates request/response serialization to a +// Provider, which handles engine-specific wire formats (e.g. OPA wraps +// requests as {"input": ...} and returns {"result": ...}). +type ExternalAuthorizer struct { + // Endpoint is the URL of the external authorization service, + // e.g. "http://opa:8181/v1/data/kagent/authz" + Endpoint string + // Provider translates between kagent's AuthzRequest/AuthzDecision + // and the engine's wire format. + Provider Provider + Client *http.Client +} + +var _ auth.Authorizer = (*ExternalAuthorizer)(nil) + +func (a *ExternalAuthorizer) Check(ctx context.Context, req auth.AuthzRequest) (*auth.AuthzDecision, error) { + body, err := a.Provider.MarshalRequest(req) + if err != nil { + return nil, fmt.Errorf("marshal authz request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.Endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create authz request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := a.Client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("authz request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("authz endpoint returned HTTP %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read authz response: %w", err) + } + + return a.Provider.UnmarshalDecision(respBody) +} diff --git a/go/internal/httpserver/auth/external_authz_test.go b/go/internal/httpserver/auth/external_authz_test.go new file mode 100644 index 000000000..91a6ceaf9 --- /dev/null +++ b/go/internal/httpserver/auth/external_authz_test.go @@ -0,0 +1,305 @@ +package auth_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + authimpl "github.com/kagent-dev/kagent/go/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +// directProvider is a pass-through provider that marshals AuthzRequest directly +// (no wrapping) and expects AuthzDecision directly (no unwrapping). +// Used to test ExternalAuthorizer transport logic independently of OPA formatting. +type directProvider struct{} + +func (p *directProvider) Name() string { return "direct" } + +func (p *directProvider) MarshalRequest(req auth.AuthzRequest) ([]byte, error) { + return json.Marshal(req) +} + +func (p *directProvider) UnmarshalDecision(data []byte) (*auth.AuthzDecision, error) { + var d auth.AuthzDecision + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil +} + +func TestExternalAuthorizer(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + timeout time.Duration + cancelCtx bool + claims map[string]any + resource auth.Resource + action auth.Verb + wantAllowed bool + wantReason string + wantErr bool + }{ + { + name: "allowed response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{Allowed: true}) //nolint:errcheck + }, + claims: map[string]any{"sub": "user-1", "groups": []string{"admin"}}, + resource: auth.Resource{Type: "Agent", Name: "default/my-agent"}, + action: auth.VerbGet, + wantAllowed: true, + }, + { + name: "denied with reason", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{ //nolint:errcheck + Allowed: false, + Reason: "user not in admin group", + }) + }, + claims: map[string]any{"sub": "user-2"}, + resource: auth.Resource{Type: "Agent", Name: "default/restricted-agent"}, + action: auth.VerbDelete, + wantAllowed: false, + wantReason: "user not in admin group", + }, + { + name: "non-200 status", + handler: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + }, + claims: map[string]any{"sub": "user-3"}, + resource: auth.Resource{Type: "Agent", Name: "default/any-agent"}, + action: auth.VerbCreate, + wantErr: true, + }, + { + name: "malformed JSON", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{invalid")) //nolint:errcheck + }, + claims: map[string]any{"sub": "user-4"}, + resource: auth.Resource{Type: "Agent", Name: "default/any-agent"}, + action: auth.VerbGet, + wantErr: true, + }, + { + name: "timeout", + handler: func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{Allowed: true}) //nolint:errcheck + }, + timeout: 50 * time.Millisecond, + claims: map[string]any{"sub": "user-5"}, + resource: auth.Resource{Type: "Agent", Name: "default/any-agent"}, + action: auth.VerbGet, + wantErr: true, + }, + { + name: "nil claims", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{Allowed: true}) //nolint:errcheck + }, + claims: nil, + resource: auth.Resource{Type: "Session", Name: "session-1"}, + action: auth.VerbGet, + wantAllowed: true, + }, + { + name: "request body correctness", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected POST", http.StatusMethodNotAllowed) + return + } + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "expected application/json", http.StatusUnsupportedMediaType) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body failed", http.StatusInternalServerError) + return + } + + // directProvider sends AuthzRequest without wrapping + var req auth.AuthzRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if req.Resource.Type != "Agent" || req.Resource.Name != "default/test-agent" { + http.Error(w, "unexpected resource", http.StatusBadRequest) + return + } + if req.Action != auth.VerbUpdate { + http.Error(w, "unexpected action", http.StatusBadRequest) + return + } + if req.Claims["sub"] != "validator" { + http.Error(w, "unexpected claims", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{Allowed: true}) //nolint:errcheck + }, + claims: map[string]any{"sub": "validator"}, + resource: auth.Resource{Type: "Agent", Name: "default/test-agent"}, + action: auth.VerbUpdate, + wantAllowed: true, + }, + { + name: "context cancellation", + handler: func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(auth.AuthzDecision{Allowed: true}) //nolint:errcheck + }, + cancelCtx: true, + claims: map[string]any{"sub": "user-6"}, + resource: auth.Resource{Type: "Agent", Name: "default/any-agent"}, + action: auth.VerbGet, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + timeout := 5 * time.Second + if tt.timeout > 0 { + timeout = tt.timeout + } + + authorizer := &authimpl.ExternalAuthorizer{ + Endpoint: server.URL, + Provider: &directProvider{}, + Client: &http.Client{Timeout: timeout}, + } + + ctx := context.Background() + if tt.cancelCtx { + cancelCtx, cancel := context.WithCancel(ctx) + cancel() + ctx = cancelCtx + } + + decision, err := authorizer.Check(ctx, auth.AuthzRequest{ + Claims: tt.claims, + Resource: tt.resource, + Action: tt.action, + }) + + if (err != nil) != tt.wantErr { + t.Fatalf("Check() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if decision != nil { + t.Errorf("Check() returned non-nil decision on error: %+v", decision) + } + return + } + if decision == nil { + t.Fatal("Check() returned nil decision without error") + } + if decision.Allowed != tt.wantAllowed { + t.Errorf("Check() Allowed = %v, want %v", decision.Allowed, tt.wantAllowed) + } + if decision.Reason != tt.wantReason { + t.Errorf("Check() Reason = %q, want %q", decision.Reason, tt.wantReason) + } + }) + } +} + +// TestExternalAuthorizerWithOPAProvider tests the full integration with OPAProvider +// using an httptest server that speaks OPA's wire format. +func TestExternalAuthorizerWithOPAProvider(t *testing.T) { + opaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body failed", http.StatusInternalServerError) + return + } + + // Verify OPA-formatted request: {"input": {...}} + var envelope struct { + Input auth.AuthzRequest `json:"input"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + http.Error(w, "invalid OPA request format", http.StatusBadRequest) + return + } + + // Make a decision based on the input + allowed := false + reason := "denied by default" + if envelope.Input.Claims != nil { + if sub, ok := envelope.Input.Claims["sub"].(string); ok && sub == "admin" { + allowed = true + reason = "" + } + } + + // Respond in OPA format: {"result": {...}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "result": map[string]any{ + "allowed": allowed, + "reason": reason, + }, + }) + })) + defer opaServer.Close() + + authorizer := &authimpl.ExternalAuthorizer{ + Endpoint: opaServer.URL, + Provider: &authimpl.OPAProvider{}, + Client: &http.Client{Timeout: 5 * time.Second}, + } + + // Test allowed request + decision, err := authorizer.Check(context.Background(), auth.AuthzRequest{ + Claims: map[string]any{"sub": "admin"}, + Resource: auth.Resource{Type: "Agent", Name: "default/my-agent"}, + Action: auth.VerbGet, + }) + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if !decision.Allowed { + t.Errorf("expected allowed, got denied: %s", decision.Reason) + } + + // Test denied request + decision, err = authorizer.Check(context.Background(), auth.AuthzRequest{ + Claims: map[string]any{"sub": "user-1"}, + Resource: auth.Resource{Type: "Agent", Name: "default/my-agent"}, + Action: auth.VerbDelete, + }) + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if decision.Allowed { + t.Error("expected denied, got allowed") + } + if decision.Reason != "denied by default" { + t.Errorf("Reason = %q, want %q", decision.Reason, "denied by default") + } +} diff --git a/go/internal/httpserver/auth/provider.go b/go/internal/httpserver/auth/provider.go new file mode 100644 index 000000000..da943e5a9 --- /dev/null +++ b/go/internal/httpserver/auth/provider.go @@ -0,0 +1,29 @@ +package auth + +import ( + "fmt" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +// Provider translates between kagent's AuthzRequest/AuthzDecision and +// engine-specific wire formats (e.g. OPA's {"input":...}/{"result":...}). +type Provider interface { + // Name returns the provider identifier (e.g. "opa"). + Name() string + // MarshalRequest serializes an AuthzRequest into the engine's wire format. + MarshalRequest(req auth.AuthzRequest) ([]byte, error) + // UnmarshalDecision deserializes the engine's response into an AuthzDecision. + UnmarshalDecision(data []byte) (*auth.AuthzDecision, error) +} + +// ProviderByName returns a Provider for the given name. +// An empty name defaults to OPA. +func ProviderByName(name string) (Provider, error) { + switch name { + case "opa", "": + return &OPAProvider{}, nil + default: + return nil, fmt.Errorf("unknown authz provider: %q (supported: opa)", name) + } +} diff --git a/go/internal/httpserver/auth/provider_opa.go b/go/internal/httpserver/auth/provider_opa.go new file mode 100644 index 000000000..e0a96c3cf --- /dev/null +++ b/go/internal/httpserver/auth/provider_opa.go @@ -0,0 +1,37 @@ +package auth + +import ( + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +// OPAProvider translates between kagent's authorization types and +// OPA's wire format. Requests are wrapped as {"input": } +// and responses are unwrapped from {"result": }. +type OPAProvider struct{} + +var _ Provider = (*OPAProvider)(nil) + +type opaRequest struct { + Input auth.AuthzRequest `json:"input"` +} + +type opaResponse struct { + Result auth.AuthzDecision `json:"result"` +} + +func (p *OPAProvider) Name() string { return "opa" } + +func (p *OPAProvider) MarshalRequest(req auth.AuthzRequest) ([]byte, error) { + return json.Marshal(opaRequest{Input: req}) +} + +func (p *OPAProvider) UnmarshalDecision(data []byte) (*auth.AuthzDecision, error) { + var resp opaResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode OPA response: %w", err) + } + return &resp.Result, nil +} diff --git a/go/internal/httpserver/auth/provider_test.go b/go/internal/httpserver/auth/provider_test.go new file mode 100644 index 000000000..27fb544e9 --- /dev/null +++ b/go/internal/httpserver/auth/provider_test.go @@ -0,0 +1,177 @@ +package auth_test + +import ( + "encoding/json" + "testing" + + authimpl "github.com/kagent-dev/kagent/go/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +func TestOPAProviderName(t *testing.T) { + p := &authimpl.OPAProvider{} + if got := p.Name(); got != "opa" { + t.Errorf("Name() = %q, want %q", got, "opa") + } +} + +func TestOPAProviderMarshalRequest(t *testing.T) { + tests := []struct { + name string + req auth.AuthzRequest + wantClaims bool + }{ + { + name: "full request", + req: auth.AuthzRequest{ + Claims: map[string]any{"sub": "user-1", "groups": []string{"admin"}}, + Resource: auth.Resource{Type: "Agent", Name: "default/my-agent"}, + Action: auth.VerbGet, + }, + wantClaims: true, + }, + { + name: "nil claims", + req: auth.AuthzRequest{ + Claims: nil, + Resource: auth.Resource{Type: "Session", Name: "session-1"}, + Action: auth.VerbCreate, + }, + wantClaims: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &authimpl.OPAProvider{} + data, err := p.MarshalRequest(tt.req) + if err != nil { + t.Fatalf("MarshalRequest() error = %v", err) + } + + // Verify the JSON has {"input": {...}} wrapper + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + + inputRaw, ok := envelope["input"] + if !ok { + t.Fatal("MarshalRequest() JSON missing 'input' key") + } + + // Verify the inner request round-trips + var inner auth.AuthzRequest + if err := json.Unmarshal(inputRaw, &inner); err != nil { + t.Fatalf("unmarshal inner request: %v", err) + } + + if inner.Resource.Type != tt.req.Resource.Type { + t.Errorf("Resource.Type = %q, want %q", inner.Resource.Type, tt.req.Resource.Type) + } + if inner.Resource.Name != tt.req.Resource.Name { + t.Errorf("Resource.Name = %q, want %q", inner.Resource.Name, tt.req.Resource.Name) + } + if inner.Action != tt.req.Action { + t.Errorf("Action = %q, want %q", inner.Action, tt.req.Action) + } + if tt.wantClaims && inner.Claims == nil { + t.Error("expected non-nil Claims") + } + if !tt.wantClaims && inner.Claims != nil { + t.Errorf("expected nil Claims, got %v", inner.Claims) + } + }) + } +} + +func TestOPAProviderUnmarshalDecision(t *testing.T) { + tests := []struct { + name string + input string + wantAllowed bool + wantReason string + wantErr bool + }{ + { + name: "allowed", + input: `{"result": {"allowed": true}}`, + wantAllowed: true, + }, + { + name: "denied with reason", + input: `{"result": {"allowed": false, "reason": "not in admin group"}}`, + wantAllowed: false, + wantReason: "not in admin group", + }, + { + name: "empty result defaults to denied", + input: `{"result": {}}`, + wantAllowed: false, + }, + { + name: "malformed JSON", + input: `{invalid`, + wantErr: true, + }, + { + name: "no result key defaults to zero value", + input: `{}`, + wantAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &authimpl.OPAProvider{} + decision, err := p.UnmarshalDecision([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Fatalf("UnmarshalDecision() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if decision == nil { + t.Fatal("UnmarshalDecision() returned nil without error") + } + if decision.Allowed != tt.wantAllowed { + t.Errorf("Allowed = %v, want %v", decision.Allowed, tt.wantAllowed) + } + if decision.Reason != tt.wantReason { + t.Errorf("Reason = %q, want %q", decision.Reason, tt.wantReason) + } + }) + } +} + +func TestProviderByName(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantErr bool + }{ + {name: "opa", input: "opa", wantName: "opa"}, + {name: "empty defaults to opa", input: "", wantName: "opa"}, + {name: "unknown provider", input: "foobar", wantErr: true}, + {name: "cerbos not yet supported", input: "cerbos", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := authimpl.ProviderByName(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ProviderByName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if tt.wantErr { + if p != nil { + t.Errorf("ProviderByName(%q) returned non-nil provider on error", tt.input) + } + return + } + if p.Name() != tt.wantName { + t.Errorf("ProviderByName(%q).Name() = %q, want %q", tt.input, p.Name(), tt.wantName) + } + }) + } +} diff --git a/go/internal/httpserver/handlers/helpers.go b/go/internal/httpserver/handlers/helpers.go index 5e5ddf2d2..f22b17b64 100644 --- a/go/internal/httpserver/handlers/helpers.go +++ b/go/internal/httpserver/handlers/helpers.go @@ -54,10 +54,8 @@ func GetUserID(r *http.Request) (string, error) { } func Check(authorizer auth.Authorizer, r *http.Request, res auth.Resource) *errors.APIError { - principal, err := GetPrincipal(r) - if err != nil { - return errors.NewBadRequestError("Failed to get user ID", err) - } + log := ctrllog.Log.WithName("http-helpers") + var verb auth.Verb switch r.Method { case http.MethodGet: @@ -72,9 +70,23 @@ func Check(authorizer auth.Authorizer, r *http.Request, res auth.Resource) *erro return errors.NewBadRequestError("Unsupported HTTP method", fmt.Errorf("method %s not supported", r.Method)) } - err = authorizer.Check(r.Context(), principal, verb, res) + var claims map[string]any + if session, ok := auth.AuthSessionFrom(r.Context()); ok { + claims = session.Claims() + } + + decision, err := authorizer.Check(r.Context(), auth.AuthzRequest{ + Claims: claims, + Resource: res, + Action: verb, + }) if err != nil { - return errors.NewForbiddenError("Not authorized", err) + // Fail-open: log the error but allow access + log.Error(err, "authorization check failed, allowing access (fail-open)") + return nil + } + if decision != nil && !decision.Allowed { + return errors.NewForbiddenError(decision.Reason, fmt.Errorf("access denied: %s", decision.Reason)) } return nil } diff --git a/go/internal/httpserver/handlers/sessions_test.go b/go/internal/httpserver/handlers/sessions_test.go index 3f81a866e..2c127e614 100644 --- a/go/internal/httpserver/handlers/sessions_test.go +++ b/go/internal/httpserver/handlers/sessions_test.go @@ -27,13 +27,11 @@ import ( ) func setUser(req *http.Request, userID string) *http.Request { - ctx := auth.AuthSessionTo(req.Context(), &authimpl.SimpleSession{ - P: auth.Principal{ - User: auth.User{ - ID: userID, - }, - }, - }) + session := authimpl.NewSimpleSession( + auth.Principal{User: auth.User{ID: userID}}, + nil, + ) + ctx := auth.AuthSessionTo(req.Context(), session) return req.WithContext(ctx) } diff --git a/go/pkg/auth/auth.go b/go/pkg/auth/auth.go index 469d8619d..b2671c43b 100644 --- a/go/pkg/auth/auth.go +++ b/go/pkg/auth/auth.go @@ -16,8 +16,8 @@ const ( ) type Resource struct { - Name string - Type string + Name string `json:"name"` + Type string `json:"type"` } type User struct { @@ -28,7 +28,6 @@ type Agent struct { ID string } -// Authn type Principal struct { User User Agent Agent @@ -36,6 +35,7 @@ type Principal struct { type Session interface { Principal() Principal + Claims() map[string]any } // Responsibilities: @@ -50,18 +50,30 @@ type AuthProvider interface { UpstreamAuth(r *http.Request, session Session, upstreamPrincipal Principal) error } -// Authz -type Authorizer interface { - Check(ctx context.Context, principal Principal, verb Verb, resource Resource) error +// AuthzRequest contains the data sent to an authorizer. +type AuthzRequest struct { + Claims map[string]any `json:"claims"` + Resource Resource `json:"resource"` + Action Verb `json:"action"` +} + +// AuthzDecision represents an authorization decision. +type AuthzDecision struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` } -// context utils +// Authorizer makes authorization decisions. +// Implementations should be fail-open: if the authorizer encounters a system +// error (e.g., unreachable policy engine), it should return an error alongside +// a nil decision. The caller decides how to handle system errors. +type Authorizer interface { + Check(ctx context.Context, req AuthzRequest) (*AuthzDecision, error) +} type sessionKeyType struct{} -var ( - sessionKey = sessionKeyType{} -) +var sessionKey = sessionKeyType{} func AuthSessionFrom(ctx context.Context) (Session, bool) { v, ok := ctx.Value(sessionKey).(Session) diff --git a/go/pkg/auth/external_authz_test.go b/go/pkg/auth/external_authz_test.go new file mode 100644 index 000000000..6a7058aed --- /dev/null +++ b/go/pkg/auth/external_authz_test.go @@ -0,0 +1,106 @@ +package auth_test + +import ( + "context" + "fmt" + "testing" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +// mockAuthorizer is a test double that returns configurable decisions and errors. +type mockAuthorizer struct { + decision *auth.AuthzDecision + err error +} + +func (m *mockAuthorizer) Check(_ context.Context, _ auth.AuthzRequest) (*auth.AuthzDecision, error) { + return m.decision, m.err +} + +var _ auth.Authorizer = (*mockAuthorizer)(nil) + +func TestAuthorizerCheck(t *testing.T) { + tests := []struct { + name string + authorizer auth.Authorizer + req auth.AuthzRequest + wantAllowed bool + wantReason string + wantErr bool + }{ + { + name: "allow with claims", + authorizer: &mockAuthorizer{ + decision: &auth.AuthzDecision{Allowed: true}, + }, + req: auth.AuthzRequest{ + Claims: map[string]any{"sub": "user-1", "groups": []string{"admin"}}, + Resource: auth.Resource{Type: "agent", Name: "my-agent"}, + Action: auth.VerbGet, + }, + wantAllowed: true, + wantErr: false, + }, + { + name: "deny with reason", + authorizer: &mockAuthorizer{ + decision: &auth.AuthzDecision{Allowed: false, Reason: "insufficient permissions"}, + }, + req: auth.AuthzRequest{ + Claims: map[string]any{"sub": "user-2"}, + Resource: auth.Resource{Type: "agent", Name: "restricted-agent"}, + Action: auth.VerbDelete, + }, + wantAllowed: false, + wantReason: "insufficient permissions", + wantErr: false, + }, + { + name: "system error", + authorizer: &mockAuthorizer{ + err: fmt.Errorf("policy engine unreachable"), + }, + req: auth.AuthzRequest{ + Claims: map[string]any{"sub": "user-3"}, + Resource: auth.Resource{Type: "agent", Name: "any-agent"}, + Action: auth.VerbCreate, + }, + wantErr: true, + }, + { + name: "nil claims", + authorizer: &mockAuthorizer{ + decision: &auth.AuthzDecision{Allowed: true}, + }, + req: auth.AuthzRequest{ + Claims: nil, + Resource: auth.Resource{Type: "session", Name: "session-1"}, + Action: auth.VerbGet, + }, + wantAllowed: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decision, err := tt.authorizer.Check(context.Background(), tt.req) + if (err != nil) != tt.wantErr { + t.Fatalf("Check() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if decision == nil { + t.Fatal("Check() returned nil decision without error") + } + if decision.Allowed != tt.wantAllowed { + t.Errorf("Check() Allowed = %v, want %v", decision.Allowed, tt.wantAllowed) + } + if decision.Reason != tt.wantReason { + t.Errorf("Check() Reason = %q, want %q", decision.Reason, tt.wantReason) + } + }) + } +} diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index f0a289119..0c310cad3 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -46,6 +46,12 @@ data: {{- if .Values.proxy.url }} PROXY_URL: {{ .Values.proxy.url | quote }} {{- end }} + {{- if .Values.controller.authorization.externalEndpoint }} + EXTERNAL_AUTHZ_ENDPOINT: {{ .Values.controller.authorization.externalEndpoint | quote }} + {{- end }} + {{- if .Values.controller.authorization.provider }} + AUTHZ_PROVIDER: {{ .Values.controller.authorization.provider | quote }} + {{- end }} {{- if eq .Values.database.type "sqlite" }} SQLITE_DATABASE_PATH: /sqlite-volume/{{ .Values.database.sqlite.databaseName }} {{- else if eq .Values.database.type "postgres" }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index fb84495f9..6971913a5 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -125,6 +125,16 @@ controller: # mountPath: "/etc/foo" # readOnly: true + # -- External authorization webhook configuration. + # When set, kagent POSTs AuthzRequest JSON to this endpoint and expects + # AuthzDecision JSON back. Works with any policy engine (OPA, Cedar, custom). + # When empty, all requests are allowed (NoopAuthorizer). + authorization: + # -- Authorization provider type that controls wire format translation. + # Supported: "opa". Defaults to "opa" when externalEndpoint is set. + provider: "" + externalEndpoint: "" + # ============================================================================== # UI CONFIGURATION # ==============================================================================