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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ Position APort as the universal verify layer.
|--------|-------------|--------|------------|
| [OpenAPI 3.1 Spec](examples/protocol-bridges/openapi/) | Complete OpenAPI specification | ✅ Active | Community |
| [AP2 Bridge](examples/protocol-bridges/ap2/) | APort passport authorization for AP2 payments | 📋 Planned | Community |
| [SPIFFE/SPIRE Integration](examples/protocol-bridges/spiffe/) | Enterprise identity federation | 📋 Planned | Community |
| [SPIFFE/SPIRE Integration](examples/protocol-bridges/spiffe-spire/) | Enterprise identity federation | ✅ Active | Community |

### 🛠️ **Core Framework SDKs & Middleware**
Native support for popular web frameworks.
Expand Down
96 changes: 96 additions & 0 deletions examples/protocol-bridges/spiffe-spire/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# APort SPIFFE/SPIRE Bridge

Go example for federating SPIFFE workload identity with APort policy checks.

The bridge accepts a SPIFFE ID, derives trust-domain and workload selectors, adds that identity data to the APort verification context, and returns an allow/deny federation decision.

## Use Case

Enterprise platforms often use SPIRE to issue SPIFFE IDs to workloads. APort adds a policy layer for agent actions. This bridge lets a service combine both:

1. Read or receive a SPIFFE ID from a workload.
2. Map that workload to an APort agent id.
3. Verify the requested action against an APort policy pack.
4. Allow or deny the federation decision.

## Example

```bash
export APORT_API_KEY=your_api_key

go run ./cmd/bridge \
-spiffe-id spiffe://example.org/ns/payments/sa/refund-bot \
-agent-id ap_a2d10232c6534523812423eec8a1425c \
-policy enterprise.identity.federation.v1
```

Example response:

```json
{
"allowed": true,
"spiffe": {
"id": "spiffe://example.org/ns/payments/sa/refund-bot",
"trust_domain": "example.org",
"path": "ns/payments/sa/refund-bot",
"segments": ["ns", "payments", "sa", "refund-bot"],
"selectors": {
"ns": "payments",
"sa": "refund-bot"
}
}
}
```

## SPIRE Integration

In a SPIRE-enabled service, obtain the workload SPIFFE ID from the SPIRE Workload API or from the workload certificate SAN. Pass that ID into `Federator.Federate` along with the mapped APort agent id:

```go
federator := bridge.Federator{
Verifier: bridge.NewHTTPVerifier("https://aport.io", os.Getenv("APORT_API_KEY")),
Policy: "enterprise.identity.federation.v1",
}

result, err := federator.Federate(ctx, bridge.FederationRequest{
SPIFFEID: "spiffe://example.org/ns/payments/sa/refund-bot",
AgentID: "ap_a2d10232c6534523812423eec8a1425c",
Context: map[string]any{
"action": "process_refund",
},
})
```

## Context Sent to APort

```json
{
"spiffe": {
"id": "spiffe://example.org/ns/payments/sa/refund-bot",
"trust_domain": "example.org",
"path": "ns/payments/sa/refund-bot",
"selectors": {
"ns": "payments",
"sa": "refund-bot"
}
},
"action": "process_refund"
}
```

## Testing

```bash
go test ./...
go test ./... -run TestHTTPVerifier
go vet ./...
```

## Files

```text
cmd/bridge/main.go CLI example
internal/bridge/spiffe.go SPIFFE ID parsing
internal/bridge/aport.go APort verification client
internal/bridge/federation.go Federation decision flow
```
61 changes: 61 additions & 0 deletions examples/protocol-bridges/spiffe-spire/cmd/bridge/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"

bridge "github.com/aporthq/aport-integrations/examples/protocol-bridges/spiffe-spire/internal/bridge"
)

func main() {
var (
spiffeID = flag.String("spiffe-id", "", "SPIFFE ID to federate, for example spiffe://example.org/ns/payments/sa/refund-bot")
agentID = flag.String("agent-id", "", "APort agent id")
policy = flag.String("policy", "enterprise.identity.federation.v1", "APort policy pack")
baseURL = flag.String("aport-base-url", getenv("APORT_BASE_URL", "https://aport.io"), "APort base URL")
apiKey = flag.String("aport-api-key", os.Getenv("APORT_API_KEY"), "APort API key")
)
flag.Parse()

if *spiffeID == "" || *agentID == "" {
log.Fatal("-spiffe-id and -agent-id are required")
}

federator := bridge.Federator{
Verifier: bridge.NewHTTPVerifier(*baseURL, *apiKey),
Policy: *policy,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

result, err := federator.Federate(ctx, bridge.FederationRequest{
SPIFFEID: *spiffeID,
AgentID: *agentID,
Context: map[string]any{
"source": "cli",
},
})
if err != nil {
log.Fatal(err)
}

payload, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatal(err)
}

fmt.Println(string(payload))
}

func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
3 changes: 3 additions & 0 deletions examples/protocol-bridges/spiffe-spire/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/aporthq/aport-integrations/examples/protocol-bridges/spiffe-spire

go 1.22
187 changes: 187 additions & 0 deletions examples/protocol-bridges/spiffe-spire/internal/bridge/aport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package bridge

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

type VerificationRequest struct {
Policy string `json:"policy"`
AgentID string `json:"agent_id"`
Context map[string]any `json:"context"`
}

type VerificationResult struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}

type Verifier interface {
Verify(ctx context.Context, request VerificationRequest) (VerificationResult, error)
}

type HTTPVerifier struct {
BaseURL string
APIKey string
Client *http.Client
}

func NewHTTPVerifier(baseURL, apiKey string) HTTPVerifier {
if baseURL == "" {
baseURL = "https://aport.io"
}

return HTTPVerifier{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Client: &http.Client{
Timeout: 10 * time.Second,
},
}
}

func (verifier HTTPVerifier) Verify(ctx context.Context, request VerificationRequest) (VerificationResult, error) {
payload := map[string]any{
"context": map[string]any{
"agent_id": request.AgentID,
"policy_id": request.Policy,
"context": request.Context,
},
}
body, err := json.Marshal(payload)
if err != nil {
return VerificationResult{}, err
}

httpRequest, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
verifier.BaseURL+"/api/verify/policy/"+request.Policy,
bytes.NewReader(body),
)
if err != nil {
return VerificationResult{}, err
}

httpRequest.Header.Set("Content-Type", "application/json")
if verifier.APIKey != "" {
httpRequest.Header.Set("Authorization", "Bearer "+verifier.APIKey)
}

client := verifier.Client
if client == nil {
client = http.DefaultClient
}

response, err := client.Do(httpRequest)
if err != nil {
return VerificationResult{}, err
}
defer response.Body.Close()

responseBody, err := io.ReadAll(response.Body)
if err != nil {
return VerificationResult{}, err
}

if response.StatusCode >= 400 {
return VerificationResult{
Allowed: false,
Reason: fmt.Sprintf("APort returned status %d", response.StatusCode),
Raw: responseBody,
}, nil
}

return NormalizeAPortDecision(responseBody)
}

func NormalizeAPortDecision(body []byte) (VerificationResult, error) {
var response map[string]any
if err := json.Unmarshal(body, &response); err != nil {
return VerificationResult{}, err
}

if decision, ok := response["decision"].(map[string]any); ok {
return normalizeDecisionMap(decision, body), nil
}

if data, ok := response["data"].(map[string]any); ok {
if decision, ok := data["decision"].(map[string]any); ok {
return normalizeDecisionMap(decision, body), nil
}
}

for _, key := range []string{"verified", "allowed", "allow"} {
if value, ok := response[key].(bool); ok {
return VerificationResult{
Allowed: value,
Reason: stringValue(response["reason"], response["message"]),
Raw: body,
}, nil
}
}

return VerificationResult{
Allowed: false,
Reason: "APort response did not include a decision",
Raw: body,
}, nil
}

func normalizeDecisionMap(decision map[string]any, raw []byte) VerificationResult {
allowed, _ := decision["allow"].(bool)
return VerificationResult{
Allowed: allowed,
Reason: reasonFromDecision(decision),
Raw: raw,
}
}

func reasonFromDecision(decision map[string]any) string {
if reason, ok := decision["reason"].(string); ok {
return reason
}

reasons, ok := decision["reasons"].([]any)
if !ok {
return ""
}

parts := make([]string, 0, len(reasons))
for _, reason := range reasons {
switch value := reason.(type) {
case string:
parts = append(parts, value)
case map[string]any:
parts = append(parts, stringValue(value["message"], value["code"]))
}
}

return strings.Join(compact(parts), ", ")
}

func stringValue(values ...any) string {
for _, value := range values {
if text, ok := value.(string); ok {
return text
}
}
return ""
}

func compact(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value != "" {
out = append(out, value)
}
}
return out
}
Loading