Skip to content
Closed
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
46 changes: 44 additions & 2 deletions service/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
"connectrpc.com/validate"
"github.com/go-chi/cors"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
attrconnect "github.com/opentdf/platform/protocol/go/policy/attributes/attributesconnect"
nsconnect "github.com/opentdf/platform/protocol/go/policy/namespaces/namespacesconnect"
smconnect "github.com/opentdf/platform/protocol/go/policy/subjectmapping/subjectmappingconnect"
unsafeconnect "github.com/opentdf/platform/protocol/go/policy/unsafe/unsafeconnect"
"github.com/opentdf/platform/sdk"
sdkAudit "github.com/opentdf/platform/sdk/audit"
"github.com/opentdf/platform/service/internal/auth"
Expand All @@ -28,6 +32,7 @@ import (
"github.com/opentdf/platform/service/logger/audit"
ctxAuth "github.com/opentdf/platform/service/pkg/auth"
"github.com/opentdf/platform/service/pkg/cache"
"github.com/opentdf/platform/service/pkg/enumnormalize"
"github.com/opentdf/platform/service/tracing"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
Expand Down Expand Up @@ -62,8 +67,9 @@ type Config struct {
WellKnownConfigRegister func(namespace string, config any) error `mapstructure:"-" json:"-"`

// Programmatic interceptors injected at startup (not loaded from config)
ExtraConnectInterceptors []connect.Interceptor `mapstructure:"-" json:"-"`
ExtraIPCInterceptors []connect.Interceptor `mapstructure:"-" json:"-"`
ExtraConnectInterceptors []connect.Interceptor `mapstructure:"-" json:"-"`
ExtraIPCInterceptors []connect.Interceptor `mapstructure:"-" json:"-"`
ExtraHTTPMiddleware []func(http.Handler) http.Handler `mapstructure:"-" json:"-"`
// Port to listen on
Port int `mapstructure:"port" json:"port" default:"8080"`
Host string `mapstructure:"host,omitempty" json:"host"`
Expand Down Expand Up @@ -392,6 +398,42 @@ func newHTTPServer(c Config, connectRPC http.Handler, originalGrpcGateway http.H
tc *tls.Config
)

// Normalize shorthand enum names (e.g. "IN" → "SUBJECT_MAPPING_OPERATOR_ENUM_IN")
// in JSON request bodies before ConnectRPC deserializes them. Accepts the
// suffix after the enum type prefix, case-insensitive, while full canonical
// names continue to work unchanged. See: opentdf/platform#3338
connectRPC = enumnormalize.NewMiddleware(
[]enumnormalize.EnumFieldRule{
// Subject Mapping enums
{JSONField: "operator", Prefix: "SUBJECT_MAPPING_OPERATOR_ENUM_"},
{JSONField: "booleanOperator", Prefix: "CONDITION_BOOLEAN_TYPE_ENUM_"},
// Attribute rule type
{JSONField: "rule", Prefix: "ATTRIBUTE_RULE_TYPE_ENUM_"},
// Active state filter (list requests)
{JSONField: "state", Prefix: "ACTIVE_STATE_ENUM_"},
},
[]string{
// Subject Mapping RPCs
smconnect.SubjectMappingServiceCreateSubjectMappingProcedure,
smconnect.SubjectMappingServiceCreateSubjectConditionSetProcedure,
smconnect.SubjectMappingServiceUpdateSubjectConditionSetProcedure,
// Attribute RPCs (rule + state)
attrconnect.AttributesServiceCreateAttributeProcedure,
attrconnect.AttributesServiceUpdateAttributeProcedure,
attrconnect.AttributesServiceListAttributesProcedure,
attrconnect.AttributesServiceListAttributeValuesProcedure,
// Namespace RPCs (state)
nsconnect.NamespaceServiceListNamespacesProcedure,
// Unsafe RPCs (rule)
unsafeconnect.UnsafeServiceUnsafeUpdateAttributeProcedure,
},
)(connectRPC)

// Apply extra HTTP middleware injected by downstream consumers (e.g. DSP).
for _, mw := range c.ExtraHTTPMiddleware {
connectRPC = mw(connectRPC)
}

// Adds deprecation header to any grpcGateway responses.
var grpcGateway http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
grpcRW := &grpcGatewayResponseWriter{w: w, code: http.StatusOK}
Expand Down
129 changes: 129 additions & 0 deletions service/pkg/enumnormalize/enumnormalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package enumnormalize

import (
"bytes"
"encoding/json"
"io"
"strings"
)

// EnumFieldRule maps a JSON field name to the prefix that protobuf requires.
// When the middleware encounters a string value in a matching field that does
// not already carry the prefix, it prepends the prefix so that protojson
// recognises the canonical enum name.
type EnumFieldRule struct {
// JSONField is the protojson camelCase field name (e.g. "operator", "booleanOperator").
JSONField string
// Prefix is the proto enum type prefix including trailing underscore
// (e.g. "SUBJECT_MAPPING_OPERATOR_ENUM_").
Prefix string
// ParentField optionally scopes this rule to only match when JSONField
// appears inside an object that is a direct child of a key named
// ParentField (at any depth). This disambiguates cases where multiple
// enum types share the same field name (e.g. "type") but live under
// different parent keys (e.g. "contentExtractors" vs "tagProcessors").
// When empty, the rule matches JSONField at any position (original behavior).
ParentField string
}

// ruleLookup stores pre-built lookup tables for fast matching.
type ruleLookup struct {
// global maps field name → prefix for rules with no ParentField.
global map[string]string
// scoped maps parentField → (field name → prefix) for parent-scoped rules.
scoped map[string]map[string]string
}

// buildRuleLookup creates a ruleLookup from a set of rules.
func buildRuleLookup(rules []EnumFieldRule) ruleLookup {
rl := ruleLookup{
global: make(map[string]string),
scoped: make(map[string]map[string]string),
}
for _, r := range rules {
if r.ParentField == "" {
rl.global[r.JSONField] = r.Prefix
} else {
if rl.scoped[r.ParentField] == nil {
rl.scoped[r.ParentField] = make(map[string]string)
}
rl.scoped[r.ParentField][r.JSONField] = r.Prefix
}
}
return rl
}

// normalizeJSON rewrites shorthand enum string values in body according to
// the configured rules. Values that already carry the full prefix, numeric
// values, and fields not covered by any rule pass through unchanged.
func normalizeJSON(body []byte, rl ruleLookup) ([]byte, error) {
if len(body) == 0 || (len(rl.global) == 0 && len(rl.scoped) == 0) {
return body, nil
}

// Use json.Decoder with UseNumber to preserve numeric precision
// (avoids float64 conversion of large int64 values).
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()

var parsed any
if err := decoder.Decode(&parsed); err != nil {
// Not valid JSON — pass through and let ConnectRPC surface the error.
return body, nil //nolint:nilerr // intentional: invalid JSON is not our error to report
}

// Ensure the entire body is a single JSON value. If there are trailing
// tokens (e.g. `{"a":1}{"b":2}`), return the original body so ConnectRPC
// can reject the malformed input rather than silently dropping the tail.
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return body, nil
}

normalizeValue(parsed, rl, "")

return json.Marshal(parsed)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// normalizeValue recursively walks a decoded JSON value, normalizing string
// enum fields according to the lookup rules. parentKey tracks the key under
// which the current value was found, enabling parent-scoped rules.
func normalizeValue(v any, rl ruleLookup, parentKey string) {
switch val := v.(type) {
case map[string]any:
for key, child := range val {
// Check global rules (no parent scope)
if prefix, ok := rl.global[key]; ok {
if s, isStr := child.(string); isStr {
val[key] = applyPrefix(s, prefix)
}
}
// Check parent-scoped rules
if scopedFields, hasParent := rl.scoped[parentKey]; hasParent {
if scopedPrefix, hasField := scopedFields[key]; hasField {
if s, isStr := child.(string); isStr {
val[key] = applyPrefix(s, scopedPrefix)
}
}
}
normalizeValue(child, rl, key)
}
case []any:
// Array elements inherit the parent key so that scoped rules work
// through arrays (e.g. "contentExtractors": [{"type": "..."}]).
for _, item := range val {
normalizeValue(item, rl, parentKey)
}
}
}

// applyPrefix prepends prefix to value if it is not already present
// (case-insensitive check). The value is upper-cased before comparison and
// before prepending so that "in" and "IN" both resolve correctly.
func applyPrefix(value, prefix string) string {
upper := strings.ToUpper(value)
if strings.HasPrefix(upper, strings.ToUpper(prefix)) {
return upper
}
return prefix + upper
}
Loading
Loading