From ba39404c4e8d67c3cf7e4fcc6b94b3287b4f9f09 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Thu, 14 May 2026 18:45:37 +0200 Subject: [PATCH] feat: add SPIFFE SPIRE bridge --- README.md | 2 +- .../protocol-bridges/spiffe-spire/README.md | 96 +++++++++ .../spiffe-spire/cmd/bridge/main.go | 61 ++++++ examples/protocol-bridges/spiffe-spire/go.mod | 3 + .../spiffe-spire/internal/bridge/aport.go | 187 ++++++++++++++++++ .../internal/bridge/aport_test.go | 89 +++++++++ .../internal/bridge/federation.go | 64 ++++++ .../internal/bridge/federation_test.go | 70 +++++++ .../spiffe-spire/internal/bridge/spiffe.go | 56 ++++++ .../internal/bridge/spiffe_test.go | 36 ++++ 10 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 examples/protocol-bridges/spiffe-spire/README.md create mode 100644 examples/protocol-bridges/spiffe-spire/cmd/bridge/main.go create mode 100644 examples/protocol-bridges/spiffe-spire/go.mod create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/aport.go create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/aport_test.go create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/federation.go create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/federation_test.go create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe.go create mode 100644 examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe_test.go diff --git a/README.md b/README.md index 77cbea9..8022499 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/protocol-bridges/spiffe-spire/README.md b/examples/protocol-bridges/spiffe-spire/README.md new file mode 100644 index 0000000..c859dd3 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/README.md @@ -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 +``` diff --git a/examples/protocol-bridges/spiffe-spire/cmd/bridge/main.go b/examples/protocol-bridges/spiffe-spire/cmd/bridge/main.go new file mode 100644 index 0000000..4e60494 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/cmd/bridge/main.go @@ -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 +} diff --git a/examples/protocol-bridges/spiffe-spire/go.mod b/examples/protocol-bridges/spiffe-spire/go.mod new file mode 100644 index 0000000..68b89e2 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/go.mod @@ -0,0 +1,3 @@ +module github.com/aporthq/aport-integrations/examples/protocol-bridges/spiffe-spire + +go 1.22 diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/aport.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/aport.go new file mode 100644 index 0000000..c52ee98 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/aport.go @@ -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 +} diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/aport_test.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/aport_test.go new file mode 100644 index 0000000..d1a08e0 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/aport_test.go @@ -0,0 +1,89 @@ +package bridge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNormalizeAPortDecision(t *testing.T) { + result, err := NormalizeAPortDecision([]byte(`{"decision":{"allow":false,"reasons":[{"message":"missing capability"}]}}`)) + if err != nil { + t.Fatalf("NormalizeAPortDecision returned error: %v", err) + } + + if result.Allowed { + t.Fatal("Allowed = true, want false") + } + + if result.Reason != "missing capability" { + t.Fatalf("Reason = %q, want missing capability", result.Reason) + } +} + +func TestHTTPVerifier(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/verify/policy/enterprise.identity.federation.v1" { + t.Fatalf("path = %q", request.URL.Path) + } + + if request.Header.Get("Authorization") != "Bearer test-key" { + t.Fatalf("Authorization header = %q", request.Header.Get("Authorization")) + } + + var payload map[string]map[string]any + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + + if payload["context"]["agent_id"] != "ap_agent" { + t.Fatalf("agent_id = %v", payload["context"]["agent_id"]) + } + + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"decision":{"allow":true}}`)) + })) + defer server.Close() + + verifier := NewHTTPVerifier(server.URL, "test-key") + result, err := verifier.Verify(context.Background(), VerificationRequest{ + Policy: "enterprise.identity.federation.v1", + AgentID: "ap_agent", + Context: map[string]any{"source": "test"}, + }) + if err != nil { + t.Fatalf("Verify returned error: %v", err) + } + + if !result.Allowed { + t.Fatalf("Allowed = false, want true") + } +} + +func TestHTTPVerifierHandlesErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Error(writer, "denied", http.StatusForbidden) + })) + defer server.Close() + + verifier := NewHTTPVerifier(server.URL, "") + result, err := verifier.Verify(context.Background(), VerificationRequest{ + Policy: "enterprise.identity.federation.v1", + AgentID: "ap_agent", + Context: map[string]any{}, + }) + if err != nil { + t.Fatalf("Verify returned error: %v", err) + } + + if result.Allowed { + t.Fatal("Allowed = true, want false") + } + + if !strings.Contains(result.Reason, "403") { + t.Fatalf("Reason = %q, want status code", result.Reason) + } +} diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/federation.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/federation.go new file mode 100644 index 0000000..a133846 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/federation.go @@ -0,0 +1,64 @@ +package bridge + +import ( + "context" + "fmt" +) + +type FederationRequest struct { + SPIFFEID string `json:"spiffe_id"` + AgentID string `json:"agent_id"` + Context map[string]any `json:"context,omitempty"` +} + +type FederationResult struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` + SPIFFE SPIFFEIdentity `json:"spiffe"` + APort VerificationResult `json:"aport"` +} + +type Federator struct { + Verifier Verifier + Policy string +} + +func (federator Federator) Federate(ctx context.Context, request FederationRequest) (FederationResult, error) { + if federator.Verifier == nil { + return FederationResult{}, fmt.Errorf("verifier is required") + } + + policy := federator.Policy + if policy == "" { + policy = "enterprise.identity.federation.v1" + } + + spiffeIdentity, err := ParseSPIFFEID(request.SPIFFEID) + if err != nil { + return FederationResult{}, err + } + + contextPayload := map[string]any{ + "spiffe": spiffeIdentity, + } + + for key, value := range request.Context { + contextPayload[key] = value + } + + verification, err := federator.Verifier.Verify(ctx, VerificationRequest{ + Policy: policy, + AgentID: request.AgentID, + Context: contextPayload, + }) + if err != nil { + return FederationResult{}, err + } + + return FederationResult{ + Allowed: verification.Allowed, + Reason: verification.Reason, + SPIFFE: spiffeIdentity, + APort: verification, + }, nil +} diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/federation_test.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/federation_test.go new file mode 100644 index 0000000..51ee26e --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/federation_test.go @@ -0,0 +1,70 @@ +package bridge + +import ( + "context" + "testing" +) + +type fakeVerifier struct { + request VerificationRequest + result VerificationResult +} + +func (verifier *fakeVerifier) Verify(ctx context.Context, request VerificationRequest) (VerificationResult, error) { + verifier.request = request + return verifier.result, nil +} + +func TestFederator(t *testing.T) { + verifier := &fakeVerifier{ + result: VerificationResult{ + Allowed: true, + }, + } + federator := Federator{ + Verifier: verifier, + Policy: "enterprise.identity.federation.v1", + } + + result, err := federator.Federate(context.Background(), FederationRequest{ + SPIFFEID: "spiffe://example.org/ns/payments/sa/refund-bot", + AgentID: "ap_agent", + Context: map[string]any{ + "workload": "refund-service", + }, + }) + if err != nil { + t.Fatalf("Federate returned error: %v", err) + } + + if !result.Allowed { + t.Fatal("Allowed = false, want true") + } + + if verifier.request.Policy != "enterprise.identity.federation.v1" { + t.Fatalf("Policy = %q", verifier.request.Policy) + } + + if verifier.request.Context["workload"] != "refund-service" { + t.Fatalf("workload context = %v", verifier.request.Context["workload"]) + } + + spiffe, ok := verifier.request.Context["spiffe"].(SPIFFEIdentity) + if !ok { + t.Fatalf("spiffe context type = %T", verifier.request.Context["spiffe"]) + } + + if spiffe.TrustDomain != "example.org" { + t.Fatalf("TrustDomain = %q", spiffe.TrustDomain) + } +} + +func TestFederatorRequiresVerifier(t *testing.T) { + _, err := Federator{}.Federate(context.Background(), FederationRequest{ + SPIFFEID: "spiffe://example.org/ns/payments/sa/refund-bot", + AgentID: "ap_agent", + }) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe.go new file mode 100644 index 0000000..8c7f330 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe.go @@ -0,0 +1,56 @@ +package bridge + +import ( + "fmt" + "net/url" + "strings" +) + +type SPIFFEIdentity struct { + ID string `json:"id"` + TrustDomain string `json:"trust_domain"` + Path string `json:"path"` + Segments []string `json:"segments"` + Selectors map[string]string `json:"selectors"` +} + +func ParseSPIFFEID(raw string) (SPIFFEIdentity, error) { + parsed, err := url.Parse(raw) + if err != nil { + return SPIFFEIdentity{}, err + } + + if parsed.Scheme != "spiffe" { + return SPIFFEIdentity{}, fmt.Errorf("SPIFFE ID must use spiffe scheme") + } + + if parsed.Host == "" { + return SPIFFEIdentity{}, fmt.Errorf("SPIFFE ID must include a trust domain") + } + + path := strings.Trim(parsed.Path, "/") + if path == "" { + return SPIFFEIdentity{}, fmt.Errorf("SPIFFE ID must include a workload path") + } + + segments := strings.Split(path, "/") + selectors := mapSegments(segments) + + return SPIFFEIdentity{ + ID: raw, + TrustDomain: parsed.Host, + Path: path, + Segments: segments, + Selectors: selectors, + }, nil +} + +func mapSegments(segments []string) map[string]string { + selectors := make(map[string]string) + + for index := 0; index+1 < len(segments); index += 2 { + selectors[segments[index]] = segments[index+1] + } + + return selectors +} diff --git a/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe_test.go b/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe_test.go new file mode 100644 index 0000000..558bc79 --- /dev/null +++ b/examples/protocol-bridges/spiffe-spire/internal/bridge/spiffe_test.go @@ -0,0 +1,36 @@ +package bridge + +import "testing" + +func TestParseSPIFFEID(t *testing.T) { + identity, err := ParseSPIFFEID("spiffe://example.org/ns/payments/sa/refund-bot") + if err != nil { + t.Fatalf("ParseSPIFFEID returned error: %v", err) + } + + if identity.TrustDomain != "example.org" { + t.Fatalf("TrustDomain = %q, want example.org", identity.TrustDomain) + } + + if identity.Selectors["ns"] != "payments" { + t.Fatalf("namespace selector = %q, want payments", identity.Selectors["ns"]) + } + + if identity.Selectors["sa"] != "refund-bot" { + t.Fatalf("service account selector = %q, want refund-bot", identity.Selectors["sa"]) + } +} + +func TestParseSPIFFEIDRejectsInvalidInput(t *testing.T) { + cases := []string{ + "https://example.org/ns/payments", + "spiffe:///ns/payments", + "spiffe://example.org", + } + + for _, item := range cases { + if _, err := ParseSPIFFEID(item); err == nil { + t.Fatalf("ParseSPIFFEID(%q) expected error", item) + } + } +}