Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions sdk/go/README.md
Original file line number Diff line number Diff line change
@@ -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 ./...
```
161 changes: 161 additions & 0 deletions sdk/go/client.go
Original file line number Diff line number Diff line change
@@ -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
}
68 changes: 68 additions & 0 deletions sdk/go/client_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
41 changes: 41 additions & 0 deletions sdk/go/examples/nethttp/main.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions sdk/go/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
46 changes: 46 additions & 0 deletions sdk/go/middleware/echo/echo.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading