From c9ffe6711f0f9cf5dcfbfce4c8e11b87006c2515 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:10:33 +0000 Subject: [PATCH 1/6] Initial plan From d8248921567af5bc7f953073465a42e5b62e25e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:16:10 +0000 Subject: [PATCH 2/6] Add AI-based Explain API for authorization decisions Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- enforcer.go | 2 + enforcer_interface.go | 2 + examples/explain_example.go | 88 +++++++++++++ explain_api.go | 219 +++++++++++++++++++++++++++++++ explain_api_test.go | 250 ++++++++++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+) create mode 100644 examples/explain_example.go create mode 100644 explain_api.go create mode 100644 explain_api_test.go diff --git a/enforcer.go b/enforcer.go index ff8c5431..d6893b75 100644 --- a/enforcer.go +++ b/enforcer.go @@ -56,6 +56,8 @@ type Enforcer struct { autoNotifyWatcher bool autoNotifyDispatcher bool acceptJsonRequest bool + + explainConfig ExplainConfig } // EnforceContext is used as the first element of the parameter "rvals" in method "enforce". diff --git a/enforcer_interface.go b/enforcer_interface.go index 73365318..2c1cf84a 100644 --- a/enforcer_interface.go +++ b/enforcer_interface.go @@ -41,6 +41,7 @@ type IEnforcer interface { GetRoleManager() rbac.RoleManager SetRoleManager(rm rbac.RoleManager) SetEffector(eft effector.Effector) + SetExplainConfig(config ExplainConfig) ClearPolicy() LoadPolicy() error LoadFilteredPolicy(filter interface{}) error @@ -58,6 +59,7 @@ type IEnforcer interface { EnforceExWithMatcher(matcher string, rvals ...interface{}) (bool, []string, error) BatchEnforce(requests [][]interface{}) ([]bool, error) BatchEnforceWithMatcher(matcher string, requests [][]interface{}) ([]bool, error) + Explain(rvals ...interface{}) (string, error) /* RBAC API */ GetRolesForUser(name string, domain ...string) ([]string, error) diff --git a/examples/explain_example.go b/examples/explain_example.go new file mode 100644 index 00000000..ee8a18be --- /dev/null +++ b/examples/explain_example.go @@ -0,0 +1,88 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "log" + "time" + + "github.com/casbin/casbin/v3" +) + +func main() { + // Initialize the enforcer + e, err := casbin.NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + log.Fatal(err) + } + + // Configure the Explain API with OpenAI-compatible endpoint + // This can be OpenAI, Azure OpenAI, or any compatible API + e.SetExplainConfig(casbin.ExplainConfig{ + Endpoint: "https://api.openai.com/v1/chat/completions", + APIKey: "your-api-key-here", // Replace with your actual API key + Model: "gpt-3.5-turbo", // Or "gpt-4" for better explanations + Timeout: 30 * time.Second, + }) + + // Example 1: Explain an allowed request + fmt.Println("=== Example 1: Allowed Request ===") + allowed, err := e.Enforce("alice", "data1", "read") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Enforce result: %v\n", allowed) + + explanation, err := e.Explain("alice", "data1", "read") + if err != nil { + log.Printf("Warning: Failed to get explanation: %v\n", err) + } else { + fmt.Printf("Explanation: %s\n\n", explanation) + } + + // Example 2: Explain a denied request + fmt.Println("=== Example 2: Denied Request ===") + allowed, err = e.Enforce("alice", "data2", "write") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Enforce result: %v\n", allowed) + + explanation, err = e.Explain("alice", "data2", "write") + if err != nil { + log.Printf("Warning: Failed to get explanation: %v\n", err) + } else { + fmt.Printf("Explanation: %s\n\n", explanation) + } + + // Example 3: Explain with different subjects + fmt.Println("=== Example 3: Different Subject ===") + allowed, err = e.Enforce("bob", "data2", "write") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Enforce result: %v\n", allowed) + + explanation, err = e.Explain("bob", "data2", "write") + if err != nil { + log.Printf("Warning: Failed to get explanation: %v\n", err) + } else { + fmt.Printf("Explanation: %s\n\n", explanation) + } + + fmt.Println("Note: The Explain API requires a valid OpenAI-compatible API endpoint and key.") + fmt.Println("The explanations above will only work if you configure a valid API endpoint.") +} diff --git a/explain_api.go b/explain_api.go new file mode 100644 index 00000000..588699eb --- /dev/null +++ b/explain_api.go @@ -0,0 +1,219 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package casbin + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ExplainConfig contains configuration for AI-based explanations. +type ExplainConfig struct { + // Endpoint is the API endpoint (e.g., "https://api.openai.com/v1/chat/completions") + Endpoint string + // APIKey is the authentication key for the API + APIKey string + // Model is the model to use (e.g., "gpt-3.5-turbo", "gpt-4") + Model string + // Timeout for API requests (default: 30s) + Timeout time.Duration +} + +// aiMessage represents a message in the OpenAI chat format. +type aiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// aiChatRequest represents the request to OpenAI chat completions API. +type aiChatRequest struct { + Model string `json:"model"` + Messages []aiMessage `json:"messages"` +} + +// aiChatResponse represents the response from OpenAI chat completions API. +type aiChatResponse struct { + Choices []struct { + Message aiMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// SetExplainConfig sets the configuration for AI-based explanations. +func (e *Enforcer) SetExplainConfig(config ExplainConfig) { + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + e.explainConfig = config +} + +// Explain returns an AI-generated explanation of why Enforce returned a particular result. +// It calls the configured OpenAI-compatible API to generate a natural language explanation. +func (e *Enforcer) Explain(rvals ...interface{}) (string, error) { + if e.explainConfig.Endpoint == "" { + return "", errors.New("explain config not set, use SetExplainConfig first") + } + + // Get enforcement result and matched rules + result, matchedRules, err := e.EnforceEx(rvals...) + if err != nil { + return "", fmt.Errorf("failed to enforce: %w", err) + } + + // Build context for AI + context := e.buildExplainContext(rvals, result, matchedRules) + + // Call AI API + explanation, err := e.callAIAPI(context) + if err != nil { + return "", fmt.Errorf("failed to get AI explanation: %w", err) + } + + return explanation, nil +} + +// buildExplainContext builds the context string for AI explanation. +func (e *Enforcer) buildExplainContext(rvals []interface{}, result bool, matchedRules []string) string { + var sb strings.Builder + + // Add request information + sb.WriteString("Authorization Request:\n") + sb.WriteString(fmt.Sprintf("Subject: %v\n", rvals[0])) + if len(rvals) > 1 { + sb.WriteString(fmt.Sprintf("Object: %v\n", rvals[1])) + } + if len(rvals) > 2 { + sb.WriteString(fmt.Sprintf("Action: %v\n", rvals[2])) + } + sb.WriteString(fmt.Sprintf("\nEnforcement Result: %v\n", result)) + + // Add matched rules + if len(matchedRules) > 0 { + sb.WriteString("\nMatched Policy Rules:\n") + for _, rule := range matchedRules { + sb.WriteString(fmt.Sprintf("- %s\n", rule)) + } + } else { + sb.WriteString("\nNo policy rules matched.\n") + } + + // Add model information + sb.WriteString("\nAccess Control Model:\n") + if m, ok := e.model["m"]; ok { + for key, ast := range m { + sb.WriteString(fmt.Sprintf("Matcher (%s): %s\n", key, ast.Value)) + } + } + if eff, ok := e.model["e"]; ok { + for key, ast := range eff { + sb.WriteString(fmt.Sprintf("Effect (%s): %s\n", key, ast.Value)) + } + } + + // Add all policies + policies, _ := e.GetPolicy() + if len(policies) > 0 { + sb.WriteString("\nAll Policy Rules:\n") + for _, policy := range policies { + sb.WriteString(fmt.Sprintf("- %s\n", strings.Join(policy, ", "))) + } + } + + return sb.String() +} + +// callAIAPI calls the configured AI API to get an explanation. +func (e *Enforcer) callAIAPI(context string) (string, error) { + // Prepare the request + messages := []aiMessage{ + { + Role: "system", + Content: "You are an expert in access control and authorization systems. " + + "Explain why an authorization request was allowed or denied based on the " + + "provided access control model, policies, and enforcement result. " + + "Be clear, concise, and educational.", + }, + { + Role: "user", + Content: fmt.Sprintf("Please explain the following authorization decision:\n\n%s", context), + }, + } + + reqBody := aiChatRequest{ + Model: e.explainConfig.Model, + Messages: messages, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + req, err := http.NewRequest("POST", e.explainConfig.Endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+e.explainConfig.APIKey) + + // Execute request with timeout + client := &http.Client{ + Timeout: e.explainConfig.Timeout, + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse response + var chatResp aiChatResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + // Check for API errors + if chatResp.Error != nil { + return "", fmt.Errorf("API error: %s", chatResp.Error.Message) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Extract explanation + if len(chatResp.Choices) == 0 { + return "", errors.New("no response from AI") + } + + return chatResp.Choices[0].Message.Content, nil +} diff --git a/explain_api_test.go b/explain_api_test.go new file mode 100644 index 00000000..1f798c04 --- /dev/null +++ b/explain_api_test.go @@ -0,0 +1,250 @@ +// Copyright 2017 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package casbin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestExplainWithoutConfig tests that Explain returns error when config is not set. +func TestExplainWithoutConfig(t *testing.T) { + e, err := NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + t.Fatal(err) + } + + _, err = e.Explain("alice", "data1", "read") + if err == nil { + t.Error("Expected error when explain config is not set") + } + if !strings.Contains(err.Error(), "explain config not set") { + t.Errorf("Expected 'explain config not set' error, got: %v", err) + } +} + +// TestExplainWithMockAPI tests Explain with a mock OpenAI-compatible API. +func TestExplainWithMockAPI(t *testing.T) { + // Create a mock server that simulates OpenAI API + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type: application/json, got %s", r.Header.Get("Content-Type")) + } + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Errorf("Expected Bearer token in Authorization header, got %s", r.Header.Get("Authorization")) + } + + // Parse request to verify structure + var req aiChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("Failed to decode request: %v", err) + } + + if req.Model != "gpt-3.5-turbo" { + t.Errorf("Expected model gpt-3.5-turbo, got %s", req.Model) + } + + if len(req.Messages) != 2 { + t.Errorf("Expected 2 messages, got %d", len(req.Messages)) + } + + // Send mock response + resp := aiChatResponse{ + Choices: []struct { + Message aiMessage `json:"message"` + }{ + { + Message: aiMessage{ + Role: "assistant", + Content: "The request was allowed because alice has read permission on data1 according to the policy rule.", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + })) + defer mockServer.Close() + + // Create enforcer + e, err := NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + t.Fatal(err) + } + + // Set explain config with mock server + e.SetExplainConfig(ExplainConfig{ + Endpoint: mockServer.URL, + APIKey: "test-api-key", + Model: "gpt-3.5-turbo", + Timeout: 5 * time.Second, + }) + + // Test explanation for allowed request + explanation, err := e.Explain("alice", "data1", "read") + if err != nil { + t.Fatalf("Failed to get explanation: %v", err) + } + + if explanation == "" { + t.Error("Expected non-empty explanation") + } + + if !strings.Contains(explanation, "allowed") { + t.Errorf("Expected explanation to mention 'allowed', got: %s", explanation) + } +} + +// TestExplainDenied tests Explain for a denied request. +func TestExplainDenied(t *testing.T) { + // Create a mock server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := aiChatResponse{ + Choices: []struct { + Message aiMessage `json:"message"` + }{ + { + Message: aiMessage{ + Role: "assistant", + Content: "The request was denied because there is no policy rule that allows alice to write to data1.", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + })) + defer mockServer.Close() + + // Create enforcer + e, err := NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + t.Fatal(err) + } + + // Set explain config + e.SetExplainConfig(ExplainConfig{ + Endpoint: mockServer.URL, + APIKey: "test-api-key", + Model: "gpt-3.5-turbo", + Timeout: 5 * time.Second, + }) + + // Test explanation for denied request + explanation, err := e.Explain("alice", "data1", "write") + if err != nil { + t.Fatalf("Failed to get explanation: %v", err) + } + + if explanation == "" { + t.Error("Expected non-empty explanation") + } + + if !strings.Contains(explanation, "denied") { + t.Errorf("Expected explanation to mention 'denied', got: %s", explanation) + } +} + +// TestExplainAPIError tests handling of API errors. +func TestExplainAPIError(t *testing.T) { + // Create a mock server that returns an error + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := aiChatResponse{ + Error: &struct { + Message string `json:"message"` + }{ + Message: "Invalid API key", + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(resp) + })) + defer mockServer.Close() + + // Create enforcer + e, err := NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + t.Fatal(err) + } + + // Set explain config + e.SetExplainConfig(ExplainConfig{ + Endpoint: mockServer.URL, + APIKey: "invalid-key", + Model: "gpt-3.5-turbo", + Timeout: 5 * time.Second, + }) + + // Test that API error is properly handled + _, err = e.Explain("alice", "data1", "read") + if err == nil { + t.Error("Expected error for API failure") + } + if !strings.Contains(err.Error(), "Invalid API key") { + t.Errorf("Expected API error message, got: %v", err) + } +} + +// TestBuildExplainContext tests the context building function. +func TestBuildExplainContext(t *testing.T) { + e, err := NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") + if err != nil { + t.Fatal(err) + } + + // Test with matched rules + rvals := []interface{}{"alice", "data1", "read"} + result := true + matchedRules := []string{"alice, data1, read"} + + context := e.buildExplainContext(rvals, result, matchedRules) + + // Verify context contains expected elements + if !strings.Contains(context, "alice") { + t.Error("Context should contain subject 'alice'") + } + if !strings.Contains(context, "data1") { + t.Error("Context should contain object 'data1'") + } + if !strings.Contains(context, "read") { + t.Error("Context should contain action 'read'") + } + if !strings.Contains(context, "true") { + t.Error("Context should contain result 'true'") + } + if !strings.Contains(context, "alice, data1, read") { + t.Error("Context should contain matched rule") + } + + // Test with no matched rules + context2 := e.buildExplainContext(rvals, false, []string{}) + if !strings.Contains(context2, "No policy rules matched") { + t.Error("Context should indicate no matched rules") + } +} From 3eb6d49d72422dd88633f0f958262a1090767be9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:19:29 +0000 Subject: [PATCH 3/6] Add comprehensive documentation for Explain API Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- EXPLAIN_API.md | 176 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 EXPLAIN_API.md diff --git a/EXPLAIN_API.md b/EXPLAIN_API.md new file mode 100644 index 00000000..21c1df60 --- /dev/null +++ b/EXPLAIN_API.md @@ -0,0 +1,176 @@ +# Explain API Documentation + +The Explain API provides AI-generated natural language explanations for authorization decisions made by Casbin's Enforce API. + +## Overview + +The Explain API uses an OpenAI-compatible API to generate human-readable explanations of why an authorization request was allowed or denied. This helps developers and administrators understand the access control logic and debug permission issues. + +## Features + +- **Uses only Go standard libraries** - No external dependencies beyond what Casbin already uses +- **OpenAI-compatible** - Works with OpenAI, Azure OpenAI, or any compatible API endpoint +- **Comprehensive context** - Sends model configuration, policies, request details, and enforcement result to the AI +- **Configurable** - Supports custom endpoints, models, timeouts, and API keys + +## Quick Start + +### 1. Configure the Explain API + +```go +import ( + "time" + "github.com/casbin/casbin/v3" +) + +// Create enforcer +e, _ := casbin.NewEnforcer("model.conf", "policy.csv") + +// Configure Explain API +e.SetExplainConfig(casbin.ExplainConfig{ + Endpoint: "https://api.openai.com/v1/chat/completions", + APIKey: "your-openai-api-key", + Model: "gpt-3.5-turbo", // or "gpt-4" for better explanations + Timeout: 30 * time.Second, +}) +``` + +### 2. Get Explanations + +```go +// Check authorization +allowed, _ := e.Enforce("alice", "data1", "read") +fmt.Printf("Access allowed: %v\n", allowed) + +// Get AI explanation +explanation, err := e.Explain("alice", "data1", "read") +if err != nil { + log.Fatal(err) +} +fmt.Println("Explanation:", explanation) +``` + +## Configuration Options + +### ExplainConfig struct + +```go +type ExplainConfig struct { + // Endpoint is the API endpoint (required) + // Examples: + // - OpenAI: "https://api.openai.com/v1/chat/completions" + // - Azure OpenAI: "https://.openai.azure.com/openai/deployments//chat/completions?api-version=2023-05-15" + Endpoint string + + // APIKey is the authentication key for the API (required) + APIKey string + + // Model is the model to use (required) + // Examples: "gpt-3.5-turbo", "gpt-4", "gpt-4-turbo" + Model string + + // Timeout for API requests (optional, default: 30s) + Timeout time.Duration +} +``` + +## Usage with Different Providers + +### OpenAI + +```go +e.SetExplainConfig(casbin.ExplainConfig{ + Endpoint: "https://api.openai.com/v1/chat/completions", + APIKey: "sk-...", + Model: "gpt-3.5-turbo", +}) +``` + +### Azure OpenAI + +```go +e.SetExplainConfig(casbin.ExplainConfig{ + Endpoint: "https://my-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2023-05-15", + APIKey: "your-azure-key", + Model: "gpt-35-turbo", // Note: Azure uses different model naming +}) +``` + +### Compatible Local Models + +Any server implementing the OpenAI chat completions API format will work: + +```go +e.SetExplainConfig(casbin.ExplainConfig{ + Endpoint: "http://localhost:8000/v1/chat/completions", + APIKey: "not-needed-for-local", + Model: "local-model", +}) +``` + +## Example Output + +For a request like `e.Explain("alice", "data1", "read")` where alice is allowed to read data1: + +``` +The authorization request was allowed because there is a matching policy rule +that grants alice read permission on data1. The policy rule "p, alice, data1, read" +explicitly allows this combination of subject, object, and action. The matcher in +the model checks if the request parameters (alice, data1, read) match any policy +rule, and in this case, it finds an exact match. Therefore, the effect is to allow +the request. +``` + +For a denied request: + +``` +The authorization request was denied because there is no policy rule that allows +alice to write to data1. While there is a rule allowing alice to read data1, there +is no corresponding rule for the write action. The access control model requires +an exact match between the request and a policy rule for access to be granted. +``` + +## Error Handling + +The Explain API can fail for several reasons: + +```go +explanation, err := e.Explain("alice", "data1", "read") +if err != nil { + // Common errors: + // - Config not set: "explain config not set, use SetExplainConfig first" + // - Enforcement error: "failed to enforce: ..." + // - API error: "failed to get AI explanation: ..." + log.Printf("Failed to get explanation: %v", err) +} +``` + +## Best Practices + +1. **Set timeout appropriately** - AI API calls can be slow, especially for complex policies +2. **Handle errors gracefully** - The Explain API is optional and should not block your main authorization flow +3. **Use for debugging** - Explain is most useful during development and troubleshooting +4. **Consider costs** - Each Explain call makes an API request to your AI provider, which may incur costs +5. **Cache explanations** - If you need to explain the same request multiple times, consider caching the results + +## Limitations + +- Requires external API access (OpenAI or compatible) +- Adds latency to authorization checks (use asynchronously for production) +- Explanation quality depends on the AI model used +- API costs may apply depending on your provider + +## Implementation Details + +The Explain API: +1. Calls `EnforceEx()` internally to get the enforcement result and matched rules +2. Builds a context string containing: + - The authorization request (subject, object, action) + - The enforcement result (allowed/denied) + - Matched policy rules + - Access control model configuration (matchers, effects) + - All policy rules in the system +3. Sends this context to the configured AI API with a system prompt +4. Returns the AI-generated explanation + +The implementation uses only Go standard libraries (`net/http`, `encoding/json`, `io`, etc.) to maintain Casbin's minimal dependency footprint. From b4b755132937f5b7aea2c0dbb9bf4b01249ce4aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:50:21 +0000 Subject: [PATCH 4/6] Fix golangci-lint issues: use http.MethodPost and context with timeout Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- explain_api.go | 22 ++++++++++++---------- explain_api_test.go | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/explain_api.go b/explain_api.go index 588699eb..253dace3 100644 --- a/explain_api.go +++ b/explain_api.go @@ -16,6 +16,7 @@ package casbin import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -81,10 +82,10 @@ func (e *Enforcer) Explain(rvals ...interface{}) (string, error) { } // Build context for AI - context := e.buildExplainContext(rvals, result, matchedRules) + explainContext := e.buildExplainContext(rvals, result, matchedRules) // Call AI API - explanation, err := e.callAIAPI(context) + explanation, err := e.callAIAPI(explainContext) if err != nil { return "", fmt.Errorf("failed to get AI explanation: %w", err) } @@ -143,7 +144,7 @@ func (e *Enforcer) buildExplainContext(rvals []interface{}, result bool, matched } // callAIAPI calls the configured AI API to get an explanation. -func (e *Enforcer) callAIAPI(context string) (string, error) { +func (e *Enforcer) callAIAPI(explainContext string) (string, error) { // Prepare the request messages := []aiMessage{ { @@ -155,7 +156,7 @@ func (e *Enforcer) callAIAPI(context string) (string, error) { }, { Role: "user", - Content: fmt.Sprintf("Please explain the following authorization decision:\n\n%s", context), + Content: fmt.Sprintf("Please explain the following authorization decision:\n\n%s", explainContext), }, } @@ -169,8 +170,11 @@ func (e *Enforcer) callAIAPI(context string) (string, error) { return "", fmt.Errorf("failed to marshal request: %w", err) } - // Create HTTP request - req, err := http.NewRequest("POST", e.explainConfig.Endpoint, bytes.NewBuffer(jsonData)) + // Create HTTP request with context + reqCtx, cancel := context.WithTimeout(context.Background(), e.explainConfig.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, e.explainConfig.Endpoint, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } @@ -178,10 +182,8 @@ func (e *Enforcer) callAIAPI(context string) (string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+e.explainConfig.APIKey) - // Execute request with timeout - client := &http.Client{ - Timeout: e.explainConfig.Timeout, - } + // Execute request + client := &http.Client{} resp, err := client.Do(req) if err != nil { diff --git a/explain_api_test.go b/explain_api_test.go index 1f798c04..d16dcaa8 100644 --- a/explain_api_test.go +++ b/explain_api_test.go @@ -44,7 +44,7 @@ func TestExplainWithMockAPI(t *testing.T) { // Create a mock server that simulates OpenAI API mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request - if r.Method != "POST" { + if r.Method != http.MethodPost { t.Errorf("Expected POST request, got %s", r.Method) } if r.Header.Get("Content-Type") != "application/json" { From 46275293bc389f976925b02ae75793ad5e722bc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 08:34:04 +0000 Subject: [PATCH 5/6] Rename AI interface from ExplainConfig to AIConfig for broader use Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- EXPLAIN_API.md | 176 -------------------------- explain_api.go => ai_api.go | 22 ++-- explain_api_test.go => ai_api_test.go | 18 +-- enforcer.go | 2 +- enforcer_interface.go | 2 +- examples/explain_example.go | 2 +- 6 files changed, 23 insertions(+), 199 deletions(-) delete mode 100644 EXPLAIN_API.md rename explain_api.go => ai_api.go (90%) rename explain_api_test.go => ai_api_test.go (94%) diff --git a/EXPLAIN_API.md b/EXPLAIN_API.md deleted file mode 100644 index 21c1df60..00000000 --- a/EXPLAIN_API.md +++ /dev/null @@ -1,176 +0,0 @@ -# Explain API Documentation - -The Explain API provides AI-generated natural language explanations for authorization decisions made by Casbin's Enforce API. - -## Overview - -The Explain API uses an OpenAI-compatible API to generate human-readable explanations of why an authorization request was allowed or denied. This helps developers and administrators understand the access control logic and debug permission issues. - -## Features - -- **Uses only Go standard libraries** - No external dependencies beyond what Casbin already uses -- **OpenAI-compatible** - Works with OpenAI, Azure OpenAI, or any compatible API endpoint -- **Comprehensive context** - Sends model configuration, policies, request details, and enforcement result to the AI -- **Configurable** - Supports custom endpoints, models, timeouts, and API keys - -## Quick Start - -### 1. Configure the Explain API - -```go -import ( - "time" - "github.com/casbin/casbin/v3" -) - -// Create enforcer -e, _ := casbin.NewEnforcer("model.conf", "policy.csv") - -// Configure Explain API -e.SetExplainConfig(casbin.ExplainConfig{ - Endpoint: "https://api.openai.com/v1/chat/completions", - APIKey: "your-openai-api-key", - Model: "gpt-3.5-turbo", // or "gpt-4" for better explanations - Timeout: 30 * time.Second, -}) -``` - -### 2. Get Explanations - -```go -// Check authorization -allowed, _ := e.Enforce("alice", "data1", "read") -fmt.Printf("Access allowed: %v\n", allowed) - -// Get AI explanation -explanation, err := e.Explain("alice", "data1", "read") -if err != nil { - log.Fatal(err) -} -fmt.Println("Explanation:", explanation) -``` - -## Configuration Options - -### ExplainConfig struct - -```go -type ExplainConfig struct { - // Endpoint is the API endpoint (required) - // Examples: - // - OpenAI: "https://api.openai.com/v1/chat/completions" - // - Azure OpenAI: "https://.openai.azure.com/openai/deployments//chat/completions?api-version=2023-05-15" - Endpoint string - - // APIKey is the authentication key for the API (required) - APIKey string - - // Model is the model to use (required) - // Examples: "gpt-3.5-turbo", "gpt-4", "gpt-4-turbo" - Model string - - // Timeout for API requests (optional, default: 30s) - Timeout time.Duration -} -``` - -## Usage with Different Providers - -### OpenAI - -```go -e.SetExplainConfig(casbin.ExplainConfig{ - Endpoint: "https://api.openai.com/v1/chat/completions", - APIKey: "sk-...", - Model: "gpt-3.5-turbo", -}) -``` - -### Azure OpenAI - -```go -e.SetExplainConfig(casbin.ExplainConfig{ - Endpoint: "https://my-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2023-05-15", - APIKey: "your-azure-key", - Model: "gpt-35-turbo", // Note: Azure uses different model naming -}) -``` - -### Compatible Local Models - -Any server implementing the OpenAI chat completions API format will work: - -```go -e.SetExplainConfig(casbin.ExplainConfig{ - Endpoint: "http://localhost:8000/v1/chat/completions", - APIKey: "not-needed-for-local", - Model: "local-model", -}) -``` - -## Example Output - -For a request like `e.Explain("alice", "data1", "read")` where alice is allowed to read data1: - -``` -The authorization request was allowed because there is a matching policy rule -that grants alice read permission on data1. The policy rule "p, alice, data1, read" -explicitly allows this combination of subject, object, and action. The matcher in -the model checks if the request parameters (alice, data1, read) match any policy -rule, and in this case, it finds an exact match. Therefore, the effect is to allow -the request. -``` - -For a denied request: - -``` -The authorization request was denied because there is no policy rule that allows -alice to write to data1. While there is a rule allowing alice to read data1, there -is no corresponding rule for the write action. The access control model requires -an exact match between the request and a policy rule for access to be granted. -``` - -## Error Handling - -The Explain API can fail for several reasons: - -```go -explanation, err := e.Explain("alice", "data1", "read") -if err != nil { - // Common errors: - // - Config not set: "explain config not set, use SetExplainConfig first" - // - Enforcement error: "failed to enforce: ..." - // - API error: "failed to get AI explanation: ..." - log.Printf("Failed to get explanation: %v", err) -} -``` - -## Best Practices - -1. **Set timeout appropriately** - AI API calls can be slow, especially for complex policies -2. **Handle errors gracefully** - The Explain API is optional and should not block your main authorization flow -3. **Use for debugging** - Explain is most useful during development and troubleshooting -4. **Consider costs** - Each Explain call makes an API request to your AI provider, which may incur costs -5. **Cache explanations** - If you need to explain the same request multiple times, consider caching the results - -## Limitations - -- Requires external API access (OpenAI or compatible) -- Adds latency to authorization checks (use asynchronously for production) -- Explanation quality depends on the AI model used -- API costs may apply depending on your provider - -## Implementation Details - -The Explain API: -1. Calls `EnforceEx()` internally to get the enforcement result and matched rules -2. Builds a context string containing: - - The authorization request (subject, object, action) - - The enforcement result (allowed/denied) - - Matched policy rules - - Access control model configuration (matchers, effects) - - All policy rules in the system -3. Sends this context to the configured AI API with a system prompt -4. Returns the AI-generated explanation - -The implementation uses only Go standard libraries (`net/http`, `encoding/json`, `io`, etc.) to maintain Casbin's minimal dependency footprint. diff --git a/explain_api.go b/ai_api.go similarity index 90% rename from explain_api.go rename to ai_api.go index 253dace3..0537c071 100644 --- a/explain_api.go +++ b/ai_api.go @@ -26,8 +26,8 @@ import ( "time" ) -// ExplainConfig contains configuration for AI-based explanations. -type ExplainConfig struct { +// AIConfig contains configuration for AI API calls. +type AIConfig struct { // Endpoint is the API endpoint (e.g., "https://api.openai.com/v1/chat/completions") Endpoint string // APIKey is the authentication key for the API @@ -60,19 +60,19 @@ type aiChatResponse struct { } `json:"error,omitempty"` } -// SetExplainConfig sets the configuration for AI-based explanations. -func (e *Enforcer) SetExplainConfig(config ExplainConfig) { +// SetAIConfig sets the configuration for AI API calls. +func (e *Enforcer) SetAIConfig(config AIConfig) { if config.Timeout == 0 { config.Timeout = 30 * time.Second } - e.explainConfig = config + e.aiConfig = config } // Explain returns an AI-generated explanation of why Enforce returned a particular result. // It calls the configured OpenAI-compatible API to generate a natural language explanation. func (e *Enforcer) Explain(rvals ...interface{}) (string, error) { - if e.explainConfig.Endpoint == "" { - return "", errors.New("explain config not set, use SetExplainConfig first") + if e.aiConfig.Endpoint == "" { + return "", errors.New("AI config not set, use SetAIConfig first") } // Get enforcement result and matched rules @@ -161,7 +161,7 @@ func (e *Enforcer) callAIAPI(explainContext string) (string, error) { } reqBody := aiChatRequest{ - Model: e.explainConfig.Model, + Model: e.aiConfig.Model, Messages: messages, } @@ -171,16 +171,16 @@ func (e *Enforcer) callAIAPI(explainContext string) (string, error) { } // Create HTTP request with context - reqCtx, cancel := context.WithTimeout(context.Background(), e.explainConfig.Timeout) + reqCtx, cancel := context.WithTimeout(context.Background(), e.aiConfig.Timeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, e.explainConfig.Endpoint, bytes.NewBuffer(jsonData)) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, e.aiConfig.Endpoint, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+e.explainConfig.APIKey) + req.Header.Set("Authorization", "Bearer "+e.aiConfig.APIKey) // Execute request client := &http.Client{} diff --git a/explain_api_test.go b/ai_api_test.go similarity index 94% rename from explain_api_test.go rename to ai_api_test.go index d16dcaa8..89a08bd1 100644 --- a/explain_api_test.go +++ b/ai_api_test.go @@ -32,10 +32,10 @@ func TestExplainWithoutConfig(t *testing.T) { _, err = e.Explain("alice", "data1", "read") if err == nil { - t.Error("Expected error when explain config is not set") + t.Error("Expected error when AI config is not set") } - if !strings.Contains(err.Error(), "explain config not set") { - t.Errorf("Expected 'explain config not set' error, got: %v", err) + if !strings.Contains(err.Error(), "AI config not set") { + t.Errorf("Expected 'AI config not set' error, got: %v", err) } } @@ -94,8 +94,8 @@ func TestExplainWithMockAPI(t *testing.T) { t.Fatal(err) } - // Set explain config with mock server - e.SetExplainConfig(ExplainConfig{ + // Set AI config with mock server + e.SetAIConfig(AIConfig{ Endpoint: mockServer.URL, APIKey: "test-api-key", Model: "gpt-3.5-turbo", @@ -146,8 +146,8 @@ func TestExplainDenied(t *testing.T) { t.Fatal(err) } - // Set explain config - e.SetExplainConfig(ExplainConfig{ + // Set AI config + e.SetAIConfig(AIConfig{ Endpoint: mockServer.URL, APIKey: "test-api-key", Model: "gpt-3.5-turbo", @@ -193,8 +193,8 @@ func TestExplainAPIError(t *testing.T) { t.Fatal(err) } - // Set explain config - e.SetExplainConfig(ExplainConfig{ + // Set AI config + e.SetAIConfig(AIConfig{ Endpoint: mockServer.URL, APIKey: "invalid-key", Model: "gpt-3.5-turbo", diff --git a/enforcer.go b/enforcer.go index d6893b75..a6bf1740 100644 --- a/enforcer.go +++ b/enforcer.go @@ -57,7 +57,7 @@ type Enforcer struct { autoNotifyDispatcher bool acceptJsonRequest bool - explainConfig ExplainConfig + aiConfig AIConfig } // EnforceContext is used as the first element of the parameter "rvals" in method "enforce". diff --git a/enforcer_interface.go b/enforcer_interface.go index 2c1cf84a..94baf84e 100644 --- a/enforcer_interface.go +++ b/enforcer_interface.go @@ -41,7 +41,7 @@ type IEnforcer interface { GetRoleManager() rbac.RoleManager SetRoleManager(rm rbac.RoleManager) SetEffector(eft effector.Effector) - SetExplainConfig(config ExplainConfig) + SetAIConfig(config AIConfig) ClearPolicy() LoadPolicy() error LoadFilteredPolicy(filter interface{}) error diff --git a/examples/explain_example.go b/examples/explain_example.go index ee8a18be..0e5426da 100644 --- a/examples/explain_example.go +++ b/examples/explain_example.go @@ -31,7 +31,7 @@ func main() { // Configure the Explain API with OpenAI-compatible endpoint // This can be OpenAI, Azure OpenAI, or any compatible API - e.SetExplainConfig(casbin.ExplainConfig{ + e.SetAIConfig(casbin.AIConfig{ Endpoint: "https://api.openai.com/v1/chat/completions", APIKey: "your-api-key-here", // Replace with your actual API key Model: "gpt-3.5-turbo", // Or "gpt-4" for better explanations From c5c107fbd1f38597bce2a95a859a396dcbb9b0c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 08:40:11 +0000 Subject: [PATCH 6/6] Remove example file and update copyright year to 2026 Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- ai_api.go | 2 +- ai_api_test.go | 2 +- examples/explain_example.go | 88 ------------------------------------- 3 files changed, 2 insertions(+), 90 deletions(-) delete mode 100644 examples/explain_example.go diff --git a/ai_api.go b/ai_api.go index 0537c071..78cef557 100644 --- a/ai_api.go +++ b/ai_api.go @@ -1,4 +1,4 @@ -// Copyright 2017 The casbin Authors. All Rights Reserved. +// Copyright 2026 The casbin Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/ai_api_test.go b/ai_api_test.go index 89a08bd1..4ff0b4e9 100644 --- a/ai_api_test.go +++ b/ai_api_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The casbin Authors. All Rights Reserved. +// Copyright 2026 The casbin Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/examples/explain_example.go b/examples/explain_example.go deleted file mode 100644 index 0e5426da..00000000 --- a/examples/explain_example.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2017 The casbin Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "fmt" - "log" - "time" - - "github.com/casbin/casbin/v3" -) - -func main() { - // Initialize the enforcer - e, err := casbin.NewEnforcer("examples/basic_model.conf", "examples/basic_policy.csv") - if err != nil { - log.Fatal(err) - } - - // Configure the Explain API with OpenAI-compatible endpoint - // This can be OpenAI, Azure OpenAI, or any compatible API - e.SetAIConfig(casbin.AIConfig{ - Endpoint: "https://api.openai.com/v1/chat/completions", - APIKey: "your-api-key-here", // Replace with your actual API key - Model: "gpt-3.5-turbo", // Or "gpt-4" for better explanations - Timeout: 30 * time.Second, - }) - - // Example 1: Explain an allowed request - fmt.Println("=== Example 1: Allowed Request ===") - allowed, err := e.Enforce("alice", "data1", "read") - if err != nil { - log.Fatal(err) - } - fmt.Printf("Enforce result: %v\n", allowed) - - explanation, err := e.Explain("alice", "data1", "read") - if err != nil { - log.Printf("Warning: Failed to get explanation: %v\n", err) - } else { - fmt.Printf("Explanation: %s\n\n", explanation) - } - - // Example 2: Explain a denied request - fmt.Println("=== Example 2: Denied Request ===") - allowed, err = e.Enforce("alice", "data2", "write") - if err != nil { - log.Fatal(err) - } - fmt.Printf("Enforce result: %v\n", allowed) - - explanation, err = e.Explain("alice", "data2", "write") - if err != nil { - log.Printf("Warning: Failed to get explanation: %v\n", err) - } else { - fmt.Printf("Explanation: %s\n\n", explanation) - } - - // Example 3: Explain with different subjects - fmt.Println("=== Example 3: Different Subject ===") - allowed, err = e.Enforce("bob", "data2", "write") - if err != nil { - log.Fatal(err) - } - fmt.Printf("Enforce result: %v\n", allowed) - - explanation, err = e.Explain("bob", "data2", "write") - if err != nil { - log.Printf("Warning: Failed to get explanation: %v\n", err) - } else { - fmt.Printf("Explanation: %s\n\n", explanation) - } - - fmt.Println("Note: The Explain API requires a valid OpenAI-compatible API endpoint and key.") - fmt.Println("The explanations above will only work if you configure a valid API endpoint.") -}