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
5 changes: 5 additions & 0 deletions gateway/gateway-runtime/api/proto/python_executor.proto
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ message AuthContext {
map<string, string> properties = 9;
AuthContext previous = 10;
string token_id = 11;
// typed_properties preserves structured claim values (arrays/objects) that the flat
// `properties` map flattens to strings. Numbers are stored as float64 (per
// google.protobuf.Struct/JSON), so integers larger than 2^53 should be represented as strings
// if exact precision is required.
google.protobuf.Struct typed_properties = 12;
}

message RequestHeaderContext {
Expand Down
2 changes: 1 addition & 1 deletion gateway/gateway-runtime/policy-engine/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/wso2/api-platform/common v0.0.0-20260326194347-3d85c50eae71
github.com/wso2/api-platform/sdk/core v0.3.0
github.com/wso2/api-platform/sdk/core v0.3.2
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
Expand Down
4 changes: 2 additions & 2 deletions gateway/gateway-runtime/policy-engine/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wso2/api-platform/sdk/core v0.3.0 h1:iPUEwB1lpjQto/Gjgl1F4ZrrU4P85bXvmabxgBSHs7E=
github.com/wso2/api-platform/sdk/core v0.3.0/go.mod h1:NZMDIQadQDpbynlCLYdZT6duiHwnf7emr7SbLHdCjaE=
github.com/wso2/api-platform/sdk/core v0.3.2 h1:8eEEDUvJ2lYI3WsRq4XiskX+yBYOPbpaLCSakjwE5Bs=
github.com/wso2/api-platform/sdk/core v0.3.2/go.mod h1:TgjpOk3QBPc7xEQC+NctWcNq5dSPBkn7wSKh+hULTj8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ func (t *Translator) ToProtoSharedContext(shared *policy.SharedContext) (*proto.
return nil, fmt.Errorf("convert shared metadata: %w", err)
}

authContext, err := t.toProtoAuthContext(shared.AuthContext)
if err != nil {
return nil, fmt.Errorf("convert auth context: %w", err)
}

return &proto.SharedContext{
ProjectId: shared.ProjectID,
RequestId: shared.RequestID,
Expand All @@ -56,7 +61,7 @@ func (t *Translator) ToProtoSharedContext(shared *policy.SharedContext) (*proto.
ApiKind: string(shared.APIKind),
ApiContext: shared.APIContext,
OperationPath: shared.OperationPath,
AuthContext: t.toProtoAuthContext(shared.AuthContext),
AuthContext: authContext,
}, nil
}

Expand Down Expand Up @@ -286,9 +291,9 @@ func (t *Translator) ToGoStreamingResponseAction(resp *proto.StreamResponse) (po
}
}

func (t *Translator) toProtoAuthContext(ctx *policy.AuthContext) *proto.AuthContext {
func (t *Translator) toProtoAuthContext(ctx *policy.AuthContext) (*proto.AuthContext, error) {
if ctx == nil {
return nil
return nil, nil
}

scopes := make(map[string]bool, len(ctx.Scopes))
Expand All @@ -301,19 +306,40 @@ func (t *Translator) toProtoAuthContext(ctx *policy.AuthContext) *proto.AuthCont
properties[key] = value
}

return &proto.AuthContext{
Authenticated: ctx.Authenticated,
Authorized: ctx.Authorized,
AuthType: ctx.AuthType,
Subject: ctx.Subject,
Issuer: ctx.Issuer,
TokenId: ctx.TokenId,
Audience: append([]string(nil), ctx.Audience...),
Scopes: scopes,
CredentialId: ctx.CredentialID,
Properties: properties,
Previous: t.toProtoAuthContext(ctx.Previous),
// Fail closed: if the structured claims cannot be serialized (e.g. an unsupported nested
// value type), surface the error rather than dropping TypedProperties. Silently omitting it
// would make downstream policies see the claim as absent and could change an authorization
// decision. Numeric note: google.protobuf.Struct carries numbers as IEEE-754 doubles, so
// integer-valued claims lose exactness beyond 2^53 — this matches how JSON/JWT claims are
// already decoded (float64) and introduces no additional loss.
var typedProperties *structpb.Struct
if len(ctx.TypedProperties) > 0 {
s, err := structpb.NewStruct(ctx.TypedProperties)
if err != nil {
return nil, fmt.Errorf("convert auth context typed properties: %w", err)
}
typedProperties = s
}

previous, err := t.toProtoAuthContext(ctx.Previous)
if err != nil {
return nil, err
}

return &proto.AuthContext{
Authenticated: ctx.Authenticated,
Authorized: ctx.Authorized,
AuthType: ctx.AuthType,
Subject: ctx.Subject,
Issuer: ctx.Issuer,
TokenId: ctx.TokenId,
Audience: append([]string(nil), ctx.Audience...),
Scopes: scopes,
CredentialId: ctx.CredentialID,
Properties: properties,
TypedProperties: typedProperties,
Previous: previous,
}, nil
}

func (t *Translator) toGoImmediateResponse(resp *proto.ImmediateResponse) policy.ImmediateResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,17 @@ func TestTranslatorToProtoSharedContextPreservesStructuredAuth(t *testing.T) {
TokenId: "tok-abc",
Scopes: map[string]bool{"read:pets": true},
Properties: map[string]string{"tenant": "demo"},
TypedProperties: map[string]interface{}{
"roles": []interface{}{"admin", "dev"},
"dept": "platform",
},
Previous: &policy.AuthContext{
Authenticated: true,
AuthType: "apikey",
Subject: "legacy-client",
TypedProperties: map[string]interface{}{
"legacy": []interface{}{"x"},
},
},
},
}
Expand All @@ -62,6 +69,21 @@ func TestTranslatorToProtoSharedContextPreservesStructuredAuth(t *testing.T) {
assert.Equal(t, true, result.GetAuthContext().GetScopes()["read:pets"])
require.NotNil(t, result.GetAuthContext().GetPrevious())
assert.Equal(t, "apikey", result.GetAuthContext().GetPrevious().GetAuthType())

// TypedProperties must cross the boundary with structure preserved: the array-valued
// "roles" claim stays a list, and the scalar "dept" stays a string.
tp := result.GetAuthContext().GetTypedProperties()
require.NotNil(t, tp)
assert.Equal(t, "platform", tp.GetFields()["dept"].GetStringValue())
roles := tp.GetFields()["roles"].GetListValue().GetValues()
require.Len(t, roles, 2)
assert.Equal(t, "admin", roles[0].GetStringValue())
assert.Equal(t, "dev", roles[1].GetStringValue())

// Nested Previous contexts must also carry TypedProperties.
prevTP := result.GetAuthContext().GetPrevious().GetTypedProperties()
require.NotNil(t, prevTP)
require.Len(t, prevTP.GetFields()["legacy"].GetListValue().GetValues(), 1)
}

func TestTranslatorToGoRequestHeaderActionTranslatesCurrentFields(t *testing.T) {
Expand Down Expand Up @@ -183,3 +205,52 @@ func TestTranslatorToGoStreamingResponseAction(t *testing.T) {
t.Fatalf("expected analytics metadata, got %#v", term.AnalyticsMetadata)
}
}

// Fail-closed: if TypedProperties holds a value that google.protobuf.Struct cannot represent,
// translation must return an error (so the request is rejected) rather than silently dropping the
// claims — dropping them would make downstream policies see the claim as absent and could change an
// authorization decision. The unsupported value is nested (and also tested inside Previous) to
// exercise the recursive path.
func TestTranslatorToProtoSharedContextFailsClosedOnUnserializableTypedProperties(t *testing.T) {
unserializable := map[string]interface{}{
"nested": map[string]interface{}{"bad": make(chan int)}, // channels aren't representable in Struct
}
cases := map[string]*policy.AuthContext{
"top-level": {Authenticated: true, AuthType: "jwt", TypedProperties: unserializable},
"previous": {
Authenticated: true, AuthType: "jwt",
Previous: &policy.AuthContext{Authenticated: true, AuthType: "apikey", TypedProperties: unserializable},
},
}
for name, authCtx := range cases {
t.Run(name, func(t *testing.T) {
result, err := NewTranslator().ToProtoSharedContext(&policy.SharedContext{AuthContext: authCtx})
require.Error(t, err)
require.Nil(t, result)
})
}
}

// Documented numeric regression: google.protobuf.Struct has a single numeric kind (number_value /
// double). Numeric claims — which arrive as float64 from JSON/JWT decoding — are carried as a
// NumberValue; integer values beyond 2^53 are subject to double rounding. This pins the double-typed
// round-trip so a future change to the numeric representation is caught.
func TestTranslatorToProtoAuthContextCarriesNumbersAsDouble(t *testing.T) {
shared := &policy.SharedContext{
AuthContext: &policy.AuthContext{
Authenticated: true,
AuthType: "jwt",
TypedProperties: map[string]interface{}{"level": float64(42), "big": float64(1 << 53)},
},
}
result, err := NewTranslator().ToProtoSharedContext(shared)
require.NoError(t, err)

fields := result.GetAuthContext().GetTypedProperties().GetFields()
require.NotNil(t, fields["level"])
assert.Equal(t, float64(42), fields["level"].GetNumberValue())
assert.Equal(t, float64(1<<53), fields["big"].GetNumberValue())
// Stored under the number kind — never string or other.
_, isNumber := fields["level"].GetKind().(*structpb.Value_NumberValue)
assert.True(t, isNumber)
}
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ def _to_python_auth_context(proto_ctx: proto.AuthContext) -> AuthContext:
scopes=dict(proto_ctx.scopes),
credential_id=proto_ctx.credential_id,
properties=dict(proto_ctx.properties),
typed_properties=Translator.struct_to_dict(proto_ctx.typed_properties),
previous=previous,
)

Expand Down
232 changes: 116 additions & 116 deletions gateway/gateway-runtime/python-executor/proto/python_executor_pb2.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def test_shared_and_request_context_translation_preserves_auth_headers_and_vhost
shared_proto.metadata.CopyFrom(Translator.dict_to_struct({"flag": True}))
shared_proto.auth_context.scopes["read:pets"] = True
shared_proto.auth_context.properties["tenant"] = "demo"
shared_proto.auth_context.typed_properties.CopyFrom(
Translator.dict_to_struct({"roles": ["admin", "dev"], "dept": "platform"})
)

shared = self.translator.to_python_shared_context(shared_proto)

Expand All @@ -63,6 +66,9 @@ def test_shared_and_request_context_translation_preserves_auth_headers_and_vhost

self.assertTrue(shared.auth_context.authenticated)
self.assertEqual("apikey", shared.auth_context.previous.auth_type)
# typed_properties must arrive with structure intact: the array claim stays a list.
self.assertEqual(["admin", "dev"], shared.auth_context.typed_properties["roles"])
self.assertEqual("platform", shared.auth_context.typed_properties["dept"])
self.assertEqual(["one", "two"], request_ctx.headers.get("X-Trace"))
self.assertEqual("public.example.com", request_ctx.vhost)
self.assertEqual(b"payload", request_ctx.body.content)
Expand Down
2 changes: 2 additions & 0 deletions sdk-python/src/apip_sdk_core/policy/v1alpha2/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, field
from typing import Any
from datetime import datetime
from enum import Enum
from typing import Any, Callable
Expand Down Expand Up @@ -99,6 +100,7 @@ class AuthContext:
credential_id: str = ""
properties: dict[str, str] = field(default_factory=dict)
previous: "AuthContext | None" = None
typed_properties: dict[str, Any] = field(default_factory=dict)


@dataclass(slots=True)
Expand Down
Loading