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
155 changes: 119 additions & 36 deletions docs/Configuring.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions opentdf-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ server:
allowcredentials: true
# Sets the maximum age (in seconds) of a specific CORS preflight request
maxage: 3600
# Additive fields - append to base lists without replacing defaults
# Use these to add custom values without having to copy all defaults
# additionalmethods: []
# additionalheaders:
# - X-Custom-Header
# additionalexposedheaders: []
grpc:
reflectionEnabled: true # Default is false
# http:
Expand Down
102 changes: 99 additions & 3 deletions service/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,89 @@ type CORSConfig struct {
AllowCredentials bool `mapstructure:"allowcredentials" json:"allowcredentials" default:"true"`
MaxAge int `mapstructure:"maxage" json:"maxage" default:"3600"`
Debug bool `mapstructure:"debug" json:"debug"`

// Additive fields - appended to base lists at runtime without replacing defaults
AdditionalMethods []string `mapstructure:"additionalmethods" json:"additionalmethods"`
AdditionalHeaders []string `mapstructure:"additionalheaders" json:"additionalheaders"`
AdditionalExposedHeaders []string `mapstructure:"additionalexposedheaders" json:"additionalexposedheaders"`
}

// mergeStringSlices combines base and additional slices, removing duplicates.
// The order is: base items first, then additional items (preserving order within each).
// Comparison is case-sensitive.
func mergeStringSlices(base, additional []string) []string {
if len(additional) == 0 {
return base
}
if len(base) == 0 {
return additional
}

seen := make(map[string]struct{}, len(base)+len(additional))
result := make([]string, 0, len(base)+len(additional))

for _, v := range base {
if _, exists := seen[v]; !exists {
seen[v] = struct{}{}
result = append(result, v)
}
}
for _, v := range additional {
if _, exists := seen[v]; !exists {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result
}

// mergeHeaderSlices combines base and additional HTTP header slices with case-insensitive
// deduplication. HTTP headers are case-insensitive per RFC 7230, so "Authorization" and
// "authorization" are treated as duplicates. The first occurrence's original casing is preserved.
func mergeHeaderSlices(base, additional []string) []string {
if len(additional) == 0 {
return base
}
if len(base) == 0 {
return additional
}

// Use canonical header keys for case-insensitive comparison
seen := make(map[string]struct{}, len(base)+len(additional))
result := make([]string, 0, len(base)+len(additional))

for _, v := range base {
canonical := textproto.CanonicalMIMEHeaderKey(v)
if _, exists := seen[canonical]; !exists {
seen[canonical] = struct{}{}
result = append(result, v) // Preserve original casing
}
}
for _, v := range additional {
canonical := textproto.CanonicalMIMEHeaderKey(v)
if _, exists := seen[canonical]; !exists {
seen[canonical] = struct{}{}
result = append(result, v) // Preserve original casing
}
}
return result
}

// EffectiveMethods returns AllowedMethods merged with AdditionalMethods.
func (c CORSConfig) EffectiveMethods() []string {
return mergeStringSlices(c.AllowedMethods, c.AdditionalMethods)
}

// EffectiveHeaders returns AllowedHeaders merged with AdditionalHeaders.
// Uses case-insensitive deduplication since HTTP headers are case-insensitive per RFC 7230.
func (c CORSConfig) EffectiveHeaders() []string {
return mergeHeaderSlices(c.AllowedHeaders, c.AdditionalHeaders)
}

// EffectiveExposedHeaders returns ExposedHeaders merged with AdditionalExposedHeaders.
// Uses case-insensitive deduplication since HTTP headers are case-insensitive per RFC 7230.
func (c CORSConfig) EffectiveExposedHeaders() []string {
return mergeHeaderSlices(c.ExposedHeaders, c.AdditionalExposedHeaders)
}

type ConnectRPC struct {
Expand Down Expand Up @@ -314,6 +397,19 @@ func newHTTPServer(c Config, connectRPC http.Handler, originalGrpcGateway http.H
// Note: The grpc-gateway handlers are getting chained together in reverse. So the last handler is the first to be called.
// CORS
if c.CORS.Enabled {
// Compute effective values by merging base and additional lists
effectiveMethods := c.CORS.EffectiveMethods()
effectiveHeaders := c.CORS.EffectiveHeaders()
effectiveExposed := c.CORS.EffectiveExposedHeaders()

// Log effective CORS config for operator visibility
l.Info("CORS middleware enabled",
slog.Any("allowed_origins", c.CORS.AllowedOrigins),
slog.Any("effective_methods", effectiveMethods),
slog.Any("effective_headers", effectiveHeaders),
slog.Any("effective_exposed_headers", effectiveExposed),
)

corsHandler := cors.New(cors.Options{
AllowOriginFunc: func(_ *http.Request, origin string) bool {
for _, allowedOrigin := range c.CORS.AllowedOrigins {
Expand All @@ -326,9 +422,9 @@ func newHTTPServer(c Config, connectRPC http.Handler, originalGrpcGateway http.H
}
return false
},
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
AllowedMethods: effectiveMethods,
AllowedHeaders: effectiveHeaders,
ExposedHeaders: effectiveExposed,
AllowCredentials: c.CORS.AllowCredentials,
MaxAge: c.CORS.MaxAge,
Debug: c.CORS.Debug,
Expand Down
Loading
Loading