Skip to content

Commit 0fe9505

Browse files
committed
feat: add Explain() API to explain the authorization decisions via LLM API (#1678)
1 parent 6cfc463 commit 0fe9505

File tree

4 files changed

+475
-0
lines changed

4 files changed

+475
-0
lines changed

ai_api.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright 2026 The casbin Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package casbin
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"net/http"
25+
"strings"
26+
"time"
27+
)
28+
29+
// AIConfig contains configuration for AI API calls.
30+
type AIConfig struct {
31+
// Endpoint is the API endpoint (e.g., "https://api.openai.com/v1/chat/completions")
32+
Endpoint string
33+
// APIKey is the authentication key for the API
34+
APIKey string
35+
// Model is the model to use (e.g., "gpt-3.5-turbo", "gpt-4")
36+
Model string
37+
// Timeout for API requests (default: 30s)
38+
Timeout time.Duration
39+
}
40+
41+
// aiMessage represents a message in the OpenAI chat format.
42+
type aiMessage struct {
43+
Role string `json:"role"`
44+
Content string `json:"content"`
45+
}
46+
47+
// aiChatRequest represents the request to OpenAI chat completions API.
48+
type aiChatRequest struct {
49+
Model string `json:"model"`
50+
Messages []aiMessage `json:"messages"`
51+
}
52+
53+
// aiChatResponse represents the response from OpenAI chat completions API.
54+
type aiChatResponse struct {
55+
Choices []struct {
56+
Message aiMessage `json:"message"`
57+
} `json:"choices"`
58+
Error *struct {
59+
Message string `json:"message"`
60+
} `json:"error,omitempty"`
61+
}
62+
63+
// SetAIConfig sets the configuration for AI API calls.
64+
func (e *Enforcer) SetAIConfig(config AIConfig) {
65+
if config.Timeout == 0 {
66+
config.Timeout = 30 * time.Second
67+
}
68+
e.aiConfig = config
69+
}
70+
71+
// Explain returns an AI-generated explanation of why Enforce returned a particular result.
72+
// It calls the configured OpenAI-compatible API to generate a natural language explanation.
73+
func (e *Enforcer) Explain(rvals ...interface{}) (string, error) {
74+
if e.aiConfig.Endpoint == "" {
75+
return "", errors.New("AI config not set, use SetAIConfig first")
76+
}
77+
78+
// Get enforcement result and matched rules
79+
result, matchedRules, err := e.EnforceEx(rvals...)
80+
if err != nil {
81+
return "", fmt.Errorf("failed to enforce: %w", err)
82+
}
83+
84+
// Build context for AI
85+
explainContext := e.buildExplainContext(rvals, result, matchedRules)
86+
87+
// Call AI API
88+
explanation, err := e.callAIAPI(explainContext)
89+
if err != nil {
90+
return "", fmt.Errorf("failed to get AI explanation: %w", err)
91+
}
92+
93+
return explanation, nil
94+
}
95+
96+
// buildExplainContext builds the context string for AI explanation.
97+
func (e *Enforcer) buildExplainContext(rvals []interface{}, result bool, matchedRules []string) string {
98+
var sb strings.Builder
99+
100+
// Add request information
101+
sb.WriteString("Authorization Request:\n")
102+
sb.WriteString(fmt.Sprintf("Subject: %v\n", rvals[0]))
103+
if len(rvals) > 1 {
104+
sb.WriteString(fmt.Sprintf("Object: %v\n", rvals[1]))
105+
}
106+
if len(rvals) > 2 {
107+
sb.WriteString(fmt.Sprintf("Action: %v\n", rvals[2]))
108+
}
109+
sb.WriteString(fmt.Sprintf("\nEnforcement Result: %v\n", result))
110+
111+
// Add matched rules
112+
if len(matchedRules) > 0 {
113+
sb.WriteString("\nMatched Policy Rules:\n")
114+
for _, rule := range matchedRules {
115+
sb.WriteString(fmt.Sprintf("- %s\n", rule))
116+
}
117+
} else {
118+
sb.WriteString("\nNo policy rules matched.\n")
119+
}
120+
121+
// Add model information
122+
sb.WriteString("\nAccess Control Model:\n")
123+
if m, ok := e.model["m"]; ok {
124+
for key, ast := range m {
125+
sb.WriteString(fmt.Sprintf("Matcher (%s): %s\n", key, ast.Value))
126+
}
127+
}
128+
if eff, ok := e.model["e"]; ok {
129+
for key, ast := range eff {
130+
sb.WriteString(fmt.Sprintf("Effect (%s): %s\n", key, ast.Value))
131+
}
132+
}
133+
134+
// Add all policies
135+
policies, _ := e.GetPolicy()
136+
if len(policies) > 0 {
137+
sb.WriteString("\nAll Policy Rules:\n")
138+
for _, policy := range policies {
139+
sb.WriteString(fmt.Sprintf("- %s\n", strings.Join(policy, ", ")))
140+
}
141+
}
142+
143+
return sb.String()
144+
}
145+
146+
// callAIAPI calls the configured AI API to get an explanation.
147+
func (e *Enforcer) callAIAPI(explainContext string) (string, error) {
148+
// Prepare the request
149+
messages := []aiMessage{
150+
{
151+
Role: "system",
152+
Content: "You are an expert in access control and authorization systems. " +
153+
"Explain why an authorization request was allowed or denied based on the " +
154+
"provided access control model, policies, and enforcement result. " +
155+
"Be clear, concise, and educational.",
156+
},
157+
{
158+
Role: "user",
159+
Content: fmt.Sprintf("Please explain the following authorization decision:\n\n%s", explainContext),
160+
},
161+
}
162+
163+
reqBody := aiChatRequest{
164+
Model: e.aiConfig.Model,
165+
Messages: messages,
166+
}
167+
168+
jsonData, err := json.Marshal(reqBody)
169+
if err != nil {
170+
return "", fmt.Errorf("failed to marshal request: %w", err)
171+
}
172+
173+
// Create HTTP request with context
174+
reqCtx, cancel := context.WithTimeout(context.Background(), e.aiConfig.Timeout)
175+
defer cancel()
176+
177+
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, e.aiConfig.Endpoint, bytes.NewBuffer(jsonData))
178+
if err != nil {
179+
return "", fmt.Errorf("failed to create request: %w", err)
180+
}
181+
182+
req.Header.Set("Content-Type", "application/json")
183+
req.Header.Set("Authorization", "Bearer "+e.aiConfig.APIKey)
184+
185+
// Execute request
186+
client := &http.Client{}
187+
188+
resp, err := client.Do(req)
189+
if err != nil {
190+
return "", fmt.Errorf("failed to execute request: %w", err)
191+
}
192+
defer resp.Body.Close()
193+
194+
// Read response
195+
body, err := io.ReadAll(resp.Body)
196+
if err != nil {
197+
return "", fmt.Errorf("failed to read response: %w", err)
198+
}
199+
200+
// Parse response
201+
var chatResp aiChatResponse
202+
if err := json.Unmarshal(body, &chatResp); err != nil {
203+
return "", fmt.Errorf("failed to parse response: %w", err)
204+
}
205+
206+
// Check for API errors
207+
if chatResp.Error != nil {
208+
return "", fmt.Errorf("API error: %s", chatResp.Error.Message)
209+
}
210+
211+
if resp.StatusCode != http.StatusOK {
212+
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
213+
}
214+
215+
// Extract explanation
216+
if len(chatResp.Choices) == 0 {
217+
return "", errors.New("no response from AI")
218+
}
219+
220+
return chatResp.Choices[0].Message.Content, nil
221+
}

0 commit comments

Comments
 (0)