diff --git a/sdk/go/README.md b/sdk/go/README.md new file mode 100644 index 0000000..7a0d5f0 --- /dev/null +++ b/sdk/go/README.md @@ -0,0 +1,69 @@ +# APort Go SDK + +Go SDK and framework middleware for APort policy verification. + +## Install + +```bash +go get github.com/aporthq/aport-integrations/sdk/go +``` + +## Core Client + +```go +client := aport.NewClient(os.Getenv("APORT_API_KEY")) + +decision, err := client.RequirePolicy(context.Background(), "payments.refund.v1", aport.VerifyRequest{ + AgentID: "agent_123", + Context: map[string]any{ + "amount": 49.99, + }, +}) +if errors.Is(err, aport.ErrDenied) { + // Block the action. +} +``` + +## net/http Middleware + +```go +guard := aporthttp.Middleware(aporthttp.Config{ + Client: client, + PolicyID: "payments.refund.v1", +}) + +http.Handle("/refund", guard(refundHandler)) +``` + +The middleware reads the agent id from `X-Agent-ID` by default and stores the successful verification response on the request context. + +## Gin, Echo, and Fiber + +Framework adapters live under: + +- `middleware/gin` +- `middleware/echo` +- `middleware/fiber` + +Each adapter: + +- Reads the agent id from `X-Agent-ID` by default. +- Builds a request context with method and route path. +- Fails closed with HTTP 403 on verification errors or denied decisions. +- Stores the successful APort decision in framework-local context. + +## Configuration + +```go +client := aport.NewClient( + os.Getenv("APORT_API_KEY"), + aport.WithBaseURL("https://api.aport.io"), + aport.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}), +) +``` + +## Tests + +```bash +go test ./... +``` diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 0000000..47a4d28 --- /dev/null +++ b/sdk/go/client.go @@ -0,0 +1,161 @@ +package aport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultBaseURL = "https://api.aport.io" + +var ErrDenied = errors.New("aport: policy denied") + +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +type ClientOption func(*Client) + +func NewClient(apiKey string, options ...ClientOption) *Client { + client := &Client{ + apiKey: apiKey, + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } + + for _, option := range options { + option(client) + } + + return client +} + +func WithBaseURL(baseURL string) ClientOption { + return func(client *Client) { + if strings.TrimSpace(baseURL) != "" { + client.baseURL = strings.TrimRight(baseURL, "/") + } + } +} + +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(client *Client) { + if httpClient != nil { + client.httpClient = httpClient + } + } +} + +type VerifyRequest struct { + AgentID string `json:"agent_id"` + Context map[string]any `json:"context,omitempty"` +} + +type VerifyResponse struct { + Allow bool `json:"allow"` + Reasons []string `json:"reasons,omitempty"` + Decision string `json:"decision,omitempty"` + TraceID string `json:"trace_id,omitempty"` + Raw map[string]any `json:"-"` +} + +func (client *Client) VerifyPolicy(ctx context.Context, policyID string, request VerifyRequest) (*VerifyResponse, error) { + if client == nil { + return nil, errors.New("aport: nil client") + } + if strings.TrimSpace(policyID) == "" { + return nil, errors.New("aport: policy id is required") + } + if strings.TrimSpace(request.AgentID) == "" { + return nil, errors.New("aport: agent id is required") + } + + endpoint := fmt.Sprintf("%s/api/verify/policy/%s", client.baseURL, url.PathEscape(policyID)) + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("aport: encode verify request: %w", err) + } + + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("aport: create verify request: %w", err) + } + + httpRequest.Header.Set("Content-Type", "application/json") + httpRequest.Header.Set("Accept", "application/json") + if client.apiKey != "" { + httpRequest.Header.Set("Authorization", "Bearer "+client.apiKey) + } + + httpResponse, err := client.httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("aport: verify request failed: %w", err) + } + defer httpResponse.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(httpResponse.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("aport: read verify response: %w", err) + } + + if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { + return nil, &HTTPError{StatusCode: httpResponse.StatusCode, Body: string(responseBody)} + } + + var decoded map[string]any + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return nil, fmt.Errorf("aport: decode verify response: %w", err) + } + + result := &VerifyResponse{Raw: decoded} + result.Allow, _ = decoded["allow"].(bool) + result.Decision, _ = decoded["decision"].(string) + result.TraceID, _ = decoded["trace_id"].(string) + result.Reasons = stringSlice(decoded["reasons"]) + + return result, nil +} + +func (client *Client) RequirePolicy(ctx context.Context, policyID string, request VerifyRequest) (*VerifyResponse, error) { + response, err := client.VerifyPolicy(ctx, policyID, request) + if err != nil { + return nil, err + } + if !response.Allow { + return response, ErrDenied + } + return response, nil +} + +type HTTPError struct { + StatusCode int + Body string +} + +func (err *HTTPError) Error() string { + return fmt.Sprintf("aport: api returned status %d: %s", err.StatusCode, err.Body) +} + +func stringSlice(value any) []string { + items, ok := value.([]any) + if !ok { + return nil + } + + result := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + result = append(result, text) + } + } + return result +} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go new file mode 100644 index 0000000..659fbe6 --- /dev/null +++ b/sdk/go/client_test.go @@ -0,0 +1,68 @@ +package aport + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestVerifyPolicyAllowsRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/verify/policy/payments.refund.v1" { + t.Fatalf("unexpected path %s", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer test-key" { + t.Fatalf("missing authorization header") + } + + var body VerifyRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.AgentID != "agent-123" || body.Context["amount"].(float64) != 25 { + t.Fatalf("unexpected request body %#v", body) + } + + _ = json.NewEncoder(writer).Encode(map[string]any{ + "allow": true, + "decision": "allow", + "reasons": []string{"within_limit"}, + "trace_id": "trace-1", + }) + })) + defer server.Close() + + client := NewClient("test-key", WithBaseURL(server.URL)) + response, err := client.RequirePolicy(context.Background(), "payments.refund.v1", VerifyRequest{ + AgentID: "agent-123", + Context: map[string]any{"amount": 25}, + }) + if err != nil { + t.Fatal(err) + } + if !response.Allow || response.Decision != "allow" || response.TraceID != "trace-1" { + t.Fatalf("unexpected response %#v", response) + } +} + +func TestRequirePolicyReturnsDeniedError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _ = json.NewEncoder(writer).Encode(map[string]any{ + "allow": false, + "reasons": []string{"limit_exceeded"}, + }) + })) + defer server.Close() + + client := NewClient("", WithBaseURL(server.URL)) + response, err := client.RequirePolicy(context.Background(), "payments.refund.v1", VerifyRequest{AgentID: "agent-123"}) + if !errors.Is(err, ErrDenied) { + t.Fatalf("expected ErrDenied, got %v", err) + } + if response == nil || response.Allow { + t.Fatalf("expected denied response") + } +} diff --git a/sdk/go/examples/nethttp/main.go b/sdk/go/examples/nethttp/main.go new file mode 100644 index 0000000..c1a49a7 --- /dev/null +++ b/sdk/go/examples/nethttp/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "net/http" + "os" + + aport "github.com/aporthq/aport-integrations/sdk/go" + aporthttp "github.com/aporthq/aport-integrations/sdk/go/middleware/nethttp" +) + +func main() { + client := aport.NewClient(os.Getenv("APORT_API_KEY")) + + guard := aporthttp.Middleware(aporthttp.Config{ + Client: client, + PolicyID: getenv("APORT_POLICY_ID", "payments.refund.v1"), + ContextBuilder: func(request *http.Request) map[string]any { + return map[string]any{ + "method": request.Method, + "path": request.URL.Path, + "amount": request.URL.Query().Get("amount"), + } + }, + }) + + refundHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusAccepted) + _, _ = writer.Write([]byte("refund accepted\n")) + }) + + http.Handle("/refund", guard(refundHandler)) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func getenv(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 0000000..699033b --- /dev/null +++ b/sdk/go/go.mod @@ -0,0 +1,9 @@ +module github.com/aporthq/aport-integrations/sdk/go + +go 1.21 + +require ( + github.com/gofiber/fiber/v2 v2.52.5 + github.com/labstack/echo/v4 v4.12.0 + github.com/gin-gonic/gin v1.10.0 +) diff --git a/sdk/go/middleware/echo/echo.go b/sdk/go/middleware/echo/echo.go new file mode 100644 index 0000000..3dee273 --- /dev/null +++ b/sdk/go/middleware/echo/echo.go @@ -0,0 +1,46 @@ +package echoaport + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + aport "github.com/aporthq/aport-integrations/sdk/go" +) + +type Config struct { + Client *aport.Client + PolicyID string + AgentIDHeader string + ContextBuilder func(echo.Context) map[string]any +} + +func Middleware(config Config) echo.MiddlewareFunc { + agentIDHeader := config.AgentIDHeader + if agentIDHeader == "" { + agentIDHeader = "X-Agent-ID" + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + contextBody := map[string]any{"method": ctx.Request().Method, "path": ctx.Path()} + if config.ContextBuilder != nil { + contextBody = config.ContextBuilder(ctx) + } + + response, err := config.Client.RequirePolicy(ctx.Request().Context(), config.PolicyID, aport.VerifyRequest{ + AgentID: ctx.Request().Header.Get(agentIDHeader), + Context: contextBody, + }) + if err != nil { + return ctx.JSON(http.StatusForbidden, map[string]any{ + "error": "aport_policy_denied", + "decision": response, + }) + } + + ctx.Set("aport.verify_response", response) + return next(ctx) + } + } +} diff --git a/sdk/go/middleware/fiber/fiber.go b/sdk/go/middleware/fiber/fiber.go new file mode 100644 index 0000000..7e16a15 --- /dev/null +++ b/sdk/go/middleware/fiber/fiber.go @@ -0,0 +1,42 @@ +package fiberaport + +import ( + "github.com/gofiber/fiber/v2" + + aport "github.com/aporthq/aport-integrations/sdk/go" +) + +type Config struct { + Client *aport.Client + PolicyID string + AgentIDHeader string + ContextBuilder func(*fiber.Ctx) map[string]any +} + +func Middleware(config Config) fiber.Handler { + agentIDHeader := config.AgentIDHeader + if agentIDHeader == "" { + agentIDHeader = "X-Agent-ID" + } + + return func(ctx *fiber.Ctx) error { + contextBody := map[string]any{"method": ctx.Method(), "path": ctx.Path()} + if config.ContextBuilder != nil { + contextBody = config.ContextBuilder(ctx) + } + + response, err := config.Client.RequirePolicy(ctx.UserContext(), config.PolicyID, aport.VerifyRequest{ + AgentID: ctx.Get(agentIDHeader), + Context: contextBody, + }) + if err != nil { + return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "aport_policy_denied", + "decision": response, + }) + } + + ctx.Locals("aport.verify_response", response) + return ctx.Next() + } +} diff --git a/sdk/go/middleware/gin/gin.go b/sdk/go/middleware/gin/gin.go new file mode 100644 index 0000000..0cb8974 --- /dev/null +++ b/sdk/go/middleware/gin/gin.go @@ -0,0 +1,45 @@ +package ginaport + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + aport "github.com/aporthq/aport-integrations/sdk/go" +) + +type Config struct { + Client *aport.Client + PolicyID string + AgentIDHeader string + ContextBuilder func(*gin.Context) map[string]any +} + +func Middleware(config Config) gin.HandlerFunc { + agentIDHeader := config.AgentIDHeader + if agentIDHeader == "" { + agentIDHeader = "X-Agent-ID" + } + + return func(ctx *gin.Context) { + contextBody := map[string]any{"method": ctx.Request.Method, "path": ctx.FullPath()} + if config.ContextBuilder != nil { + contextBody = config.ContextBuilder(ctx) + } + + response, err := config.Client.RequirePolicy(ctx.Request.Context(), config.PolicyID, aport.VerifyRequest{ + AgentID: ctx.GetHeader(agentIDHeader), + Context: contextBody, + }) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "aport_policy_denied", + "decision": response, + }) + return + } + + ctx.Set("aport.verify_response", response) + ctx.Next() + } +} diff --git a/sdk/go/middleware/nethttp/nethttp.go b/sdk/go/middleware/nethttp/nethttp.go new file mode 100644 index 0000000..8c89f3c --- /dev/null +++ b/sdk/go/middleware/nethttp/nethttp.go @@ -0,0 +1,75 @@ +package nethttp + +import ( + "context" + "encoding/json" + "net/http" + + aport "github.com/aporthq/aport-integrations/sdk/go" +) + +type AgentIDResolver func(*http.Request) string + +type Config struct { + Client *aport.Client + PolicyID string + AgentIDResolver AgentIDResolver + ContextBuilder func(*http.Request) map[string]any + OnDenied func(http.ResponseWriter, *http.Request, *aport.VerifyResponse) +} + +type contextKey string + +const verifyResponseKey contextKey = "aportVerifyResponse" + +func Middleware(config Config) func(http.Handler) http.Handler { + resolver := config.AgentIDResolver + if resolver == nil { + resolver = func(request *http.Request) string { + return request.Header.Get("X-Agent-ID") + } + } + + onDenied := config.OnDenied + if onDenied == nil { + onDenied = defaultDeniedHandler + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + contextBody := map[string]any{ + "method": request.Method, + "path": request.URL.Path, + } + if config.ContextBuilder != nil { + contextBody = config.ContextBuilder(request) + } + + response, err := config.Client.RequirePolicy(request.Context(), config.PolicyID, aport.VerifyRequest{ + AgentID: resolver(request), + Context: contextBody, + }) + if err != nil { + onDenied(writer, request, response) + return + } + + ctx := context.WithValue(request.Context(), verifyResponseKey, response) + next.ServeHTTP(writer, request.WithContext(ctx)) + }) + } +} + +func VerifyResponse(request *http.Request) (*aport.VerifyResponse, bool) { + response, ok := request.Context().Value(verifyResponseKey).(*aport.VerifyResponse) + return response, ok +} + +func defaultDeniedHandler(writer http.ResponseWriter, _ *http.Request, response *aport.VerifyResponse) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(writer).Encode(map[string]any{ + "error": "aport_policy_denied", + "decision": response, + }) +}