Skip to content
Merged
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
83 changes: 76 additions & 7 deletions docs/apis/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2751,6 +2751,75 @@ components:
additionalProperties: true
example: { "user_id": "userid", "status": "active" }
# Attempt schemas for attempts-first API
AttemptDestination:
type: object
description: The destination an attempt was sent to, without credentials. Use the destination endpoints to retrieve credentials.
required: [id, tenant_id, type, topics, config, created_at, updated_at, disabled_at, target]
properties:
id:
type: string
description: Control plane generated ID or user provided ID for the destination.
example: "des_12345"
tenant_id:
type: string
description: The tenant the destination belongs to.
example: "tenant_123"
type:
type: string
description: Type of the destination.
example: "webhook"
topics:
$ref: "#/components/schemas/Topics"
filter:
$ref: "#/components/schemas/Filter"
config:
type: object
additionalProperties:
type: string
description: Type-specific destination configuration.
example: { "url": "https://my-service.com/webhook/handler" }
delivery_metadata:
type: object
additionalProperties:
type: string
nullable: true
description: Static key-value pairs merged into event metadata on every attempt.
example: { "app-id": "my-app", "region": "us-east-1" }
metadata:
type: object
additionalProperties:
type: string
nullable: true
description: Arbitrary contextual information stored with the destination.
example: { "internal-id": "123", "team": "platform" }
disabled_at:
type: string
format: date-time
nullable: true
description: ISO Date when the destination was disabled, or null if enabled.
example: null
created_at:
type: string
format: date-time
description: ISO Date when the destination was created.
example: "2024-01-01T00:00:00Z"
updated_at:
type: string
format: date-time
description: ISO Date when the destination was last updated.
example: "2024-01-01T00:00:00Z"
target:
type: string
description: A human-readable representation of the destination target (e.g., URL host). Read-only.
readOnly: true
example: "my-service.com"
target_url:
type: string
format: url
nullable: true
description: A URL link to the destination target. Read-only.
readOnly: true
example: "https://my-service.com/webhook/handler"
Attempt:
type: object
description: An attempt represents a single delivery attempt of an event to a destination.
Expand Down Expand Up @@ -2812,8 +2881,8 @@ components:
description: The associated event object. Only present when include=event or include=event.data.
destination:
nullable: true
$ref: "#/components/schemas/Destination"
description: The destination object. Only present when include=destination.
$ref: "#/components/schemas/AttemptDestination"
description: The destination object, without credentials. Only present when include=destination.
EventSummary:
type: object
description: Event object without data (returned when include=event).
Expand Down Expand Up @@ -3357,7 +3426,7 @@ tags:
- `include=event`: Include event summary (id, topic, time, eligible_for_retry, metadata)
- `include=event.data`: Include full event with payload data
- `include=response_data`: Include response body and headers from the attempt
- `include=destination`: Include the full destination object with target information
- `include=destination`: Include the destination object with target information, without credentials
- name: Events
description: |
An event represents a payload published to Outpost. Events are matched against all destinations whose topic subscriptions match the event topic, then delivered.
Expand Down Expand Up @@ -4034,7 +4103,7 @@ paths:
- `event`: Include event summary (id, topic, time, eligible_for_retry, metadata)
- `event.data`: Include full event with payload data
- `response_data`: Include response body and headers
- `destination`: Include the full destination object
- `destination`: Include the destination object, without credentials
- name: order_by
in: query
required: false
Expand Down Expand Up @@ -4153,7 +4222,7 @@ paths:
- `event`: Include event summary (id, topic, time, eligible_for_retry, metadata)
- `event.data`: Include full event with payload data
- `response_data`: Include response body and headers
- `destination`: Include the full destination object
- `destination`: Include the destination object, without credentials
responses:
"200":
description: Attempt details.
Expand Down Expand Up @@ -4726,7 +4795,7 @@ paths:
- `event`: Include event summary (id, topic, time, eligible_for_retry, metadata)
- `event.data`: Include full event with payload data
- `response_data`: Include response body and headers
- `destination`: Include the full destination object
- `destination`: Include the destination object, without credentials
- name: order_by
in: query
required: false
Expand Down Expand Up @@ -4824,7 +4893,7 @@ paths:
- `event`: Include event summary
- `event.data`: Include full event with payload data
- `response_data`: Include response body and headers
- `destination`: Include the full destination object
- `destination`: Include the destination object, without credentials
responses:
"200":
description: Attempt details.
Expand Down
9 changes: 8 additions & 1 deletion internal/apirouter/log_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ type APIAttempt struct {
Destination interface{} `json:"destination,omitempty"`
}

// APIAttemptDestination is the destination object when include=destination.
// Credentials are only returned by the destination endpoints.
type APIAttemptDestination struct {
*destregistry.DestinationDisplay
Credentials *struct{} `json:"credentials,omitempty"` // shadows the embedded credentials
}

// APIEventSummary is the event object when expand=event (without data)
type APIEventSummary struct {
ID string `json:"id"`
Expand Down Expand Up @@ -193,7 +200,7 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions, destDisplay *
}

if opts.Destination && destDisplay != nil {
api.Destination = destDisplay
api.Destination = APIAttemptDestination{DestinationDisplay: destDisplay}
}

return api
Expand Down
7 changes: 5 additions & 2 deletions internal/apirouter/log_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1293,7 +1293,7 @@ func TestAPI_Attempts(t *testing.T) {
h := newAPITest(t)

require.NoError(t, h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))))
dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"))
dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithCredentials(map[string]string{"secret": "whsec_test"}))
require.NoError(t, h.tenantStore.CreateDestination(t.Context(), dest))

e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithDestinationID("d1"))
Expand All @@ -1314,13 +1314,15 @@ func TestAPI_Attempts(t *testing.T) {
require.True(t, ok, "destination should be an object when include=destination")
assert.Equal(t, "d1", destMap["id"])
assert.Equal(t, "t1", destMap["tenant_id"])
assert.Contains(t, destMap, "target")
assert.NotContains(t, destMap, "credentials")
})

t.Run("include destination on list populates destination", func(t *testing.T) {
h := newAPITest(t)

require.NoError(t, h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))))
dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"))
dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithCredentials(map[string]string{"secret": "whsec_test"}))
require.NoError(t, h.tenantStore.CreateDestination(t.Context(), dest))

e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithDestinationID("d1"))
Expand All @@ -1346,6 +1348,7 @@ func TestAPI_Attempts(t *testing.T) {
destMap, ok := attempt["destination"].(map[string]any)
require.True(t, ok, "destination should be an object when include=destination")
assert.Equal(t, "d1", destMap["id"])
assert.NotContains(t, destMap, "credentials")
})

t.Run("without include destination field is omitted", func(t *testing.T) {
Expand Down
18 changes: 16 additions & 2 deletions internal/apirouter/logger_middleware_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,20 @@ func TestLoggerMiddleware_DifferentDestinationTypes(t *testing.T) {
expectedMask: []string{},
expectedKeep: []string{"https://public-api.example.com/webhook", "POST", "webhook"},
},
{
name: "credential not marked sensitive in metadata",
requestBody: map[string]interface{}{
"type": "webhook",
"config": map[string]interface{}{
"url": "https://example.com/webhook",
},
"credentials": map[string]interface{}{
"secret": "whsec_abc123def456",
},
},
expectedMask: []string{"whsec_abc123def456"},
expectedKeep: []string{"webhook", "https://example.com/webhook"},
},
{
name: "unknown destination type",
requestBody: map[string]interface{}{
Expand All @@ -478,8 +492,8 @@ func TestLoggerMiddleware_DifferentDestinationTypes(t *testing.T) {
"some_field": "some_value",
},
},
expectedMask: []string{},
expectedKeep: []string{"unknown_type", "some_value"}, // Unknown type won't be sanitized
expectedMask: []string{"some_value"},
expectedKeep: []string{"unknown_type"},
},
}

Expand Down
13 changes: 8 additions & 5 deletions internal/apirouter/sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ func (s *RequestBodySanitizer) sanitizeDestinationRequest(data map[string]interf
sanitized[k] = v
}

if credentials, ok := sanitized["credentials"].(map[string]interface{}); ok {
redacted := make(map[string]interface{}, len(credentials))
for k := range credentials {
redacted[k] = SensitiveFieldMask
}
sanitized["credentials"] = redacted
}

// Get the destination type to load metadata
destinationType, ok := sanitized["type"].(string)
if !ok {
Expand All @@ -87,11 +95,6 @@ func (s *RequestBodySanitizer) sanitizeDestinationRequest(data map[string]interf
return sanitized // If we can't load metadata, return as-is
}

// Sanitize credentials field based on metadata
if credentials, ok := sanitized["credentials"].(map[string]interface{}); ok {
sanitized["credentials"] = s.sanitizeFieldsMap(credentials, meta.CredentialFields)
}

// Also check config fields for any marked as sensitive
if config, ok := sanitized["config"].(map[string]interface{}); ok {
sanitized["config"] = s.sanitizeFieldsMap(config, meta.ConfigFields)
Expand Down
17 changes: 17 additions & 0 deletions internal/apirouter/sanitizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ func TestRequestBodySanitizer_SanitizeDestinationRequest(t *testing.T) {
}
}

func TestRequestBodySanitizer_RedactsAllCredentials(t *testing.T) {
sanitizer := &RequestBodySanitizer{}

// No type, as in a PATCH body, so metadata can't be loaded.
input := `{"credentials":{"secret":"whsec_abc123","username":"app_user"},"topics":["user.created"]}`

result, err := sanitizer.SanitizeRequestBody(strings.NewReader(input))
if err != nil {
t.Fatalf("SanitizeRequestBody() error = %v", err)
}

expected := `{"credentials":{"secret":"[REDACTED]","username":"[REDACTED]"},"topics":["user.created"]}`
if string(result) != expected {
t.Errorf("SanitizeRequestBody() = %s, want %s", result, expected)
}
}

func TestRequestBodySanitizer_SizeLimit(t *testing.T) {
sanitizer := &RequestBodySanitizer{}

Expand Down
31 changes: 25 additions & 6 deletions internal/destregistry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import (
"context"
"errors"
"fmt"
"hash"
"hash/fnv"
"maps"
"slices"
"strconv"
"strings"
"time"

"github.com/hookdeck/outpost/internal/destregistry/metadata"
Expand Down Expand Up @@ -110,8 +114,12 @@ func NewRegistry(cfg *Config, logger *logging.Logger) Registry {
onEvict := func(key string, p Publisher) {
if err := p.Close(); err != nil {
// TODO: consider how to get context for OTEL logging
// Log only the destination ID prefix, not the full key: the key now
// folds credentials and delivery metadata into an FNV hash, so the
// hash suffix is a secret-derived value that should stay out of logs.
destID, _, _ := strings.Cut(key, ".")
logger.Error("failed to close publisher on eviction",
zap.String("key", key),
zap.String("destination_id", destID),
zap.Error(err),
)
}
Expand Down Expand Up @@ -282,17 +290,28 @@ func (r *registry) ResolveProvider(destination *models.Destination) (Provider, e
return provider, nil
}

// MakePublisherKey creates a unique key for a destination that includes type and config
// MakePublisherKey creates a unique key for a destination that includes type, config,
// credentials and delivery metadata. Any of these can be baked into the publisher when
// it is created, so a change in any of them must produce a new cache key.
func MakePublisherKey(dest *models.Destination) string {
h := fnv.New64a()
for k, v := range dest.Config {
h.Write([]byte(k))
h.Write([]byte(v))
}
hashSortedMap(h, dest.Config)
hashSortedMap(h, dest.Credentials)
hashSortedMap(h, dest.DeliveryMetadata)
h.Write([]byte(dest.Type))
return dest.ID + "." + strconv.FormatUint(h.Sum64(), 36)
}

func hashSortedMap(h hash.Hash64, m map[string]string) {
for _, k := range slices.Sorted(maps.Keys(m)) {
h.Write([]byte(k))
h.Write([]byte{0})
h.Write([]byte(m[k]))
h.Write([]byte{0})
}
h.Write([]byte{0})
}

func (r *registry) ResolvePublisher(ctx context.Context, destination *models.Destination) (Publisher, error) {
key := MakePublisherKey(destination)

Expand Down
Loading
Loading