diff --git a/docs/grpc/index.html b/docs/grpc/index.html index f3710b0855..993776a455 100644 --- a/docs/grpc/index.html +++ b/docs/grpc/index.html @@ -5187,14 +5187,14 @@

EntityResolutionService

ResolveEntities ResolveEntitiesRequest ResolveEntitiesResponse -

+

Deprecated: use v2 ResolveEntities instead

CreateEntityChainFromJwt CreateEntityChainFromJwtRequest CreateEntityChainFromJwtResponse -

+

Deprecated: use v2 CreateEntityChainsFromTokens instead

diff --git a/docs/openapi/entityresolution/entity_resolution.swagger.json b/docs/openapi/entityresolution/entity_resolution.swagger.json index 8e179b635f..42616b53bf 100644 --- a/docs/openapi/entityresolution/entity_resolution.swagger.json +++ b/docs/openapi/entityresolution/entity_resolution.swagger.json @@ -18,6 +18,7 @@ "paths": { "/entityresolution/entitychain": { "post": { + "summary": "Deprecated: use v2 CreateEntityChainsFromTokens instead", "operationId": "EntityResolutionService_CreateEntityChainFromJwt", "responses": { "200": { @@ -50,6 +51,7 @@ }, "/entityresolution/resolve": { "post": { + "summary": "Deprecated: use v2 ResolveEntities instead", "operationId": "EntityResolutionService_ResolveEntities", "responses": { "200": { diff --git a/protocol/go/entityresolution/entity_resolution_grpc.pb.go b/protocol/go/entityresolution/entity_resolution_grpc.pb.go index dfcef1991d..e072f75ae9 100644 --- a/protocol/go/entityresolution/entity_resolution_grpc.pb.go +++ b/protocol/go/entityresolution/entity_resolution_grpc.pb.go @@ -27,7 +27,9 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type EntityResolutionServiceClient interface { + // Deprecated: use v2 ResolveEntities instead ResolveEntities(ctx context.Context, in *ResolveEntitiesRequest, opts ...grpc.CallOption) (*ResolveEntitiesResponse, error) + // Deprecated: use v2 CreateEntityChainsFromTokens instead CreateEntityChainFromJwt(ctx context.Context, in *CreateEntityChainFromJwtRequest, opts ...grpc.CallOption) (*CreateEntityChainFromJwtResponse, error) } @@ -61,7 +63,9 @@ func (c *entityResolutionServiceClient) CreateEntityChainFromJwt(ctx context.Con // All implementations must embed UnimplementedEntityResolutionServiceServer // for forward compatibility type EntityResolutionServiceServer interface { + // Deprecated: use v2 ResolveEntities instead ResolveEntities(context.Context, *ResolveEntitiesRequest) (*ResolveEntitiesResponse, error) + // Deprecated: use v2 CreateEntityChainsFromTokens instead CreateEntityChainFromJwt(context.Context, *CreateEntityChainFromJwtRequest) (*CreateEntityChainFromJwtResponse, error) mustEmbedUnimplementedEntityResolutionServiceServer() } diff --git a/protocol/go/entityresolution/entityresolutionconnect/entity_resolution.connect.go b/protocol/go/entityresolution/entityresolutionconnect/entity_resolution.connect.go index 05aeae6492..85e82134c5 100644 --- a/protocol/go/entityresolution/entityresolutionconnect/entity_resolution.connect.go +++ b/protocol/go/entityresolution/entityresolutionconnect/entity_resolution.connect.go @@ -51,7 +51,9 @@ var ( // EntityResolutionServiceClient is a client for the entityresolution.EntityResolutionService // service. type EntityResolutionServiceClient interface { + // Deprecated: use v2 ResolveEntities instead ResolveEntities(context.Context, *connect.Request[entityresolution.ResolveEntitiesRequest]) (*connect.Response[entityresolution.ResolveEntitiesResponse], error) + // Deprecated: use v2 CreateEntityChainsFromTokens instead CreateEntityChainFromJwt(context.Context, *connect.Request[entityresolution.CreateEntityChainFromJwtRequest]) (*connect.Response[entityresolution.CreateEntityChainFromJwtResponse], error) } @@ -99,7 +101,9 @@ func (c *entityResolutionServiceClient) CreateEntityChainFromJwt(ctx context.Con // EntityResolutionServiceHandler is an implementation of the // entityresolution.EntityResolutionService service. type EntityResolutionServiceHandler interface { + // Deprecated: use v2 ResolveEntities instead ResolveEntities(context.Context, *connect.Request[entityresolution.ResolveEntitiesRequest]) (*connect.Response[entityresolution.ResolveEntitiesResponse], error) + // Deprecated: use v2 CreateEntityChainsFromTokens instead CreateEntityChainFromJwt(context.Context, *connect.Request[entityresolution.CreateEntityChainFromJwtRequest]) (*connect.Response[entityresolution.CreateEntityChainFromJwtResponse], error) } diff --git a/sdk/go.mod b/sdk/go.mod index c6a509b07a..2dbdec1282 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -12,7 +12,7 @@ require ( github.com/lestrrat-go/jwx/v2 v2.0.21 github.com/opentdf/platform/lib/fixtures v0.2.10 github.com/opentdf/platform/lib/ocrypto v0.1.9 - github.com/opentdf/platform/protocol/go v0.3.2 + github.com/opentdf/platform/protocol/go v0.3.3 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.34.0 github.com/xeipuuv/gojsonschema v1.2.0 diff --git a/sdk/go.sum b/sdk/go.sum index 1ee9aa961e..3bf6b34267 100644 --- a/sdk/go.sum +++ b/sdk/go.sum @@ -116,8 +116,8 @@ github.com/opentdf/platform/lib/fixtures v0.2.10 h1:R688b98ctsEiDRlQSvLxmAWT7bXv github.com/opentdf/platform/lib/fixtures v0.2.10/go.mod h1:wGhclxDeDXf8bp5VAWztT1nY2gWVNGQLd8rWs5wtXV0= github.com/opentdf/platform/lib/ocrypto v0.1.9 h1:GvgPB7CoK7JmWvsSvJ0hc+RC0wezgcuRpy3q2oYKjdA= github.com/opentdf/platform/lib/ocrypto v0.1.9/go.mod h1:UTtqh8mvhAYA+sEnaMxpr/406e84L5Q1sAxtKGIXfu4= -github.com/opentdf/platform/protocol/go v0.3.2 h1:WugeSl7RSRM7e7c5jJumZOIW2jr+sMqwDzpGUGyeC5k= -github.com/opentdf/platform/protocol/go v0.3.2/go.mod h1:nErYkgt32GW22CNqSyLO+JE49C3JndI1TsVdF+CUYd4= +github.com/opentdf/platform/protocol/go v0.3.3 h1:ySnnMniOFpxnc4G2EPGQEuEzaYAfLtNhqqIkGv1XXQM= +github.com/opentdf/platform/protocol/go v0.3.3/go.mod h1:nErYkgt32GW22CNqSyLO+JE49C3JndI1TsVdF+CUYd4= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/sdk/sdk.go b/sdk/sdk.go index dd4bfcc851..dced9c9b20 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -17,6 +17,7 @@ import ( "github.com/opentdf/platform/lib/ocrypto" "github.com/opentdf/platform/protocol/go/authorization" "github.com/opentdf/platform/protocol/go/entityresolution" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/actions" "github.com/opentdf/platform/protocol/go/policy/attributes" @@ -71,6 +72,7 @@ type SDK struct { Attributes attributes.AttributesServiceClient Authorization authorization.AuthorizationServiceClient EntityResoution entityresolution.EntityResolutionServiceClient + EntityResolutionV2 entityresolutionV2.EntityResolutionServiceClient KeyAccessServerRegistry kasregistry.KeyAccessServerRegistryServiceClient Namespaces namespaces.NamespaceServiceClient RegisteredResources registeredresources.RegisteredResourcesServiceClient @@ -214,6 +216,7 @@ func New(platformEndpoint string, opts ...Option) (*SDK, error) { KeyAccessServerRegistry: kasregistry.NewKeyAccessServerRegistryServiceClient(platformConn), Authorization: authorization.NewAuthorizationServiceClient(platformConn), EntityResoution: entityresolution.NewEntityResolutionServiceClient(ersConn), + EntityResolutionV2: entityresolutionV2.NewEntityResolutionServiceClient(ersConn), KeyManagement: keymanagement.NewKeyManagementServiceClient(platformConn), wellknownConfiguration: wellknownconfiguration.NewWellKnownServiceClient(platformConn), }, nil diff --git a/service/entityresolution/claims/claims_entity_resolution.go b/service/entityresolution/claims/entity_resolution.go similarity index 99% rename from service/entityresolution/claims/claims_entity_resolution.go rename to service/entityresolution/claims/entity_resolution.go index 8a6ff92ea1..9e23d90c94 100644 --- a/service/entityresolution/claims/claims_entity_resolution.go +++ b/service/entityresolution/claims/entity_resolution.go @@ -1,4 +1,4 @@ -package entityresolution +package claims import ( "context" diff --git a/service/entityresolution/claims/claims_entity_resolution_test.go b/service/entityresolution/claims/entity_resolution_test.go similarity index 99% rename from service/entityresolution/claims/claims_entity_resolution_test.go rename to service/entityresolution/claims/entity_resolution_test.go index 671391d509..cf16ac4359 100644 --- a/service/entityresolution/claims/claims_entity_resolution_test.go +++ b/service/entityresolution/claims/entity_resolution_test.go @@ -1,4 +1,4 @@ -package entityresolution_test +package claims_test import ( "testing" diff --git a/service/entityresolution/claims/v2/entity_resolution.go b/service/entityresolution/claims/v2/entity_resolution.go new file mode 100644 index 0000000000..f9b1eee261 --- /dev/null +++ b/service/entityresolution/claims/v2/entity_resolution.go @@ -0,0 +1,146 @@ +package claims + +import ( + "context" + "fmt" + "log/slog" + "strconv" + + "connectrpc.com/connect" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + auth "github.com/opentdf/platform/service/authorization" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/pkg/config" + "github.com/opentdf/platform/service/pkg/serviceregistry" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" +) + +type EntityResolutionServiceV2 struct { + entityresolutionV2.UnimplementedEntityResolutionServiceServer + logger *logger.Logger + trace.Tracer +} + +func RegisterClaimsERS(_ config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { + claimsSVC := EntityResolutionServiceV2{logger: logger} + return claimsSVC, nil +} + +func (s EntityResolutionServiceV2) ResolveEntities(ctx context.Context, req *connect.Request[entityresolutionV2.ResolveEntitiesRequest]) (*connect.Response[entityresolutionV2.ResolveEntitiesResponse], error) { + resp, err := EntityResolution(ctx, req.Msg, s.logger) + return connect.NewResponse(&resp), err +} + +func (s EntityResolutionServiceV2) CreateEntityChainsFromTokens(ctx context.Context, req *connect.Request[entityresolutionV2.CreateEntityChainsFromTokensRequest]) (*connect.Response[entityresolutionV2.CreateEntityChainsFromTokensResponse], error) { + ctx, span := s.Tracer.Start(ctx, "CreateEntityChainsFromTokens") + defer span.End() + + resp, err := CreateEntityChainsFromTokens(ctx, req.Msg, s.logger) + return connect.NewResponse(&resp), err +} + +func CreateEntityChainsFromTokens( + _ context.Context, + req *entityresolutionV2.CreateEntityChainsFromTokensRequest, + _ *logger.Logger, +) (entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { + entityChains := []*entity.EntityChain{} + // for each token in the tokens form an entity chain + for _, tok := range req.GetTokens() { + entities, err := getEntitiesFromToken(tok.GetJwt()) + if err != nil { + return entityresolutionV2.CreateEntityChainsFromTokensResponse{}, err + } + entityChains = append(entityChains, &entity.EntityChain{EphemeralId: tok.GetEphemeralId(), Entities: entities}) + } + + return entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: entityChains}, nil +} + +func EntityResolution(_ context.Context, + req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, +) (entityresolutionV2.ResolveEntitiesResponse, error) { + payload := req.GetEntities() + var resolvedEntities []*entityresolutionV2.EntityRepresentation + + for idx, ident := range payload { + entityStruct := &structpb.Struct{} + switch ident.GetEntityType().(type) { + case *entity.Entity_Claims: + claims := ident.GetClaims() + if claims != nil { + err := claims.UnmarshalTo(entityStruct) + if err != nil { + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error unpacking anypb.Any to structpb.Struct: %w", err)) + } + } + default: + retrievedStruct, err := entityToStructPb(ident) + if err != nil { + logger.Error("unable to make entity struct", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInternal, fmt.Errorf("unable to make entity struct: %w", err)) + } + entityStruct = retrievedStruct + } + // make sure the id field is populated + originialID := ident.GetEphemeralId() + if originialID == "" { + originialID = auth.EntityIDPrefix + strconv.Itoa(idx) + } + resolvedEntities = append( + resolvedEntities, + &entityresolutionV2.EntityRepresentation{ + OriginalId: originialID, + AdditionalProps: []*structpb.Struct{entityStruct}, + }, + ) + } + return entityresolutionV2.ResolveEntitiesResponse{EntityRepresentations: resolvedEntities}, nil +} + +func getEntitiesFromToken(jwtString string) ([]*entity.Entity, error) { + token, err := jwt.ParseString(jwtString, jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("error parsing jwt: %w", err) + } + + claims := token.PrivateClaims() + entities := []*entity.Entity{} + + // Convert map[string]interface{} to *structpb.Struct + structClaims, err := structpb.NewStruct(claims) + if err != nil { + return nil, fmt.Errorf("error converting to structpb.Struct: %w", err) + } + + // Wrap the struct in an *anypb.Any message + anyClaims, err := anypb.New(structClaims) + if err != nil { + return nil, fmt.Errorf("error wrapping in anypb.Any: %w", err) + } + + entities = append(entities, &entity.Entity{ + EntityType: &entity.Entity_Claims{Claims: anyClaims}, + EphemeralId: "jwtentity-claims", + Category: entity.Entity_CATEGORY_SUBJECT, + }) + return entities, nil +} + +func entityToStructPb(ident *entity.Entity) (*structpb.Struct, error) { + entityBytes, err := protojson.Marshal(ident) + if err != nil { + return nil, err + } + var entityStruct structpb.Struct + err = entityStruct.UnmarshalJSON(entityBytes) + if err != nil { + return nil, err + } + return &entityStruct, nil +} diff --git a/service/entityresolution/claims/v2/entity_resolution_test.go b/service/entityresolution/claims/v2/entity_resolution_test.go new file mode 100644 index 0000000000..b5ac14b369 --- /dev/null +++ b/service/entityresolution/claims/v2/entity_resolution_test.go @@ -0,0 +1,116 @@ +package claims_test + +import ( + "testing" + + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + claims "github.com/opentdf/platform/service/entityresolution/claims/v2" + "github.com/opentdf/platform/service/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" +) + +const samplejwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImhlbGxvd29ybGQiLCJpYXQiOjE1MTYyMzkwMjJ9.EAOittOMzKENEAs44eaMuZe-xas7VNVsgBxhwmxYiIw" + +func Test_ClientResolveEntity(t *testing.T) { + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_ClientId{ClientId: "random"}}) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "random", propMap["clientId"]) + assert.Equal(t, "1234", propMap["ephemeralId"]) +} + +func Test_EmailResolveEntity(t *testing.T) { + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_EmailAddress{EmailAddress: "random"}}) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "random", propMap["emailAddress"]) + assert.Equal(t, "1234", propMap["ephemeralId"]) +} + +func Test_ClaimsResolveEntity(t *testing.T) { + customclaims := map[string]interface{}{ + "foo": "bar", + "baz": 42, + } + // Convert map[string]interface{} to *structpb.Struct + structClaims, err := structpb.NewStruct(customclaims) + require.NoError(t, err) + + // Wrap the struct in an *anypb.Any + anyClaims, err := anypb.New(structClaims) + require.NoError(t, err) + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}}) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "bar", propMap["foo"]) + assert.EqualValues(t, 42, propMap["baz"]) +} + +func Test_JWTToEntityChainClaims(t *testing.T) { + validBody := []*entity.Token{{Jwt: samplejwt}} + + resp, reserr := claims.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 1) + assert.IsType(t, &entity.Entity_Claims{}, resp.GetEntityChains()[0].GetEntities()[0].GetEntityType()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + + var unpackedStruct structpb.Struct + err := resp.GetEntityChains()[0].GetEntities()[0].GetClaims().UnmarshalTo(&unpackedStruct) + require.NoError(t, err) + + // Convert structpb.Struct to map[string]interface{} + claimsMap := unpackedStruct.AsMap() + + assert.Equal(t, "helloworld", claimsMap["name"]) +} diff --git a/service/entityresolution/entity_resolution.proto b/service/entityresolution/entity_resolution.proto index bbccdef37a..4b93ab2fad 100644 --- a/service/entityresolution/entity_resolution.proto +++ b/service/entityresolution/entity_resolution.proto @@ -107,12 +107,14 @@ message CreateEntityChainFromJwtResponse { service EntityResolutionService { + // Deprecated: use v2 ResolveEntities instead rpc ResolveEntities(ResolveEntitiesRequest) returns (ResolveEntitiesResponse) { option (google.api.http) = { post: "/entityresolution/resolve" body: "*"; }; } + // Deprecated: use v2 CreateEntityChainsFromTokens instead rpc CreateEntityChainFromJwt(CreateEntityChainFromJwtRequest) returns (CreateEntityChainFromJwtResponse) { option (google.api.http) = { post: "/entityresolution/entitychain" diff --git a/service/entityresolution/keycloak/keycloak_entity_resolution.go b/service/entityresolution/keycloak/entity_resolution.go similarity index 99% rename from service/entityresolution/keycloak/keycloak_entity_resolution.go rename to service/entityresolution/keycloak/entity_resolution.go index 8235274d24..420ce5d2e2 100644 --- a/service/entityresolution/keycloak/keycloak_entity_resolution.go +++ b/service/entityresolution/keycloak/entity_resolution.go @@ -1,4 +1,4 @@ -package entityresolution +package keycloak import ( "context" diff --git a/service/entityresolution/keycloak/keycloak_entity_resolution_test.go b/service/entityresolution/keycloak/entity_resolution_test.go similarity index 99% rename from service/entityresolution/keycloak/keycloak_entity_resolution_test.go rename to service/entityresolution/keycloak/entity_resolution_test.go index 0b2fb2478e..31ed7bea09 100644 --- a/service/entityresolution/keycloak/keycloak_entity_resolution_test.go +++ b/service/entityresolution/keycloak/entity_resolution_test.go @@ -1,4 +1,4 @@ -package entityresolution_test +package keycloak_test import ( "encoding/json" diff --git a/service/entityresolution/keycloak/v2/entity_resolution.go b/service/entityresolution/keycloak/v2/entity_resolution.go new file mode 100644 index 0000000000..2e45e4efb2 --- /dev/null +++ b/service/entityresolution/keycloak/v2/entity_resolution.go @@ -0,0 +1,496 @@ +package keycloak + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + + "connectrpc.com/connect" + "github.com/Nerzal/gocloak/v13" + "github.com/go-viper/mapstructure/v2" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + auth "github.com/opentdf/platform/service/authorization" + "github.com/opentdf/platform/service/logger" + "github.com/opentdf/platform/service/pkg/config" + "github.com/opentdf/platform/service/pkg/serviceregistry" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" +) + +var ( + ErrCreationFailed = errors.New("resource creation failed") + ErrGetRetrievalFailed = errors.New("resource retrieval failed") + ErrNotFound = errors.New("resource not found") +) + +const ( + ClientJwtSelector = "azp" + UsernameJwtSelector = "preferred_username" +) + +const serviceAccountUsernamePrefix = "service-account-" + +type EntityResolutionServiceV2 struct { + entityresolutionV2.UnimplementedEntityResolutionServiceServer + idpConfig Config + logger *logger.Logger + trace.Tracer +} + +type Config struct { + URL string `mapstructure:"url" json:"url"` + Realm string `mapstructure:"realm" json:"realm"` + ClientID string `mapstructure:"clientid" json:"clientid"` + ClientSecret string `mapstructure:"clientsecret" json:"clientsecret"` + LegacyKeycloak bool `mapstructure:"legacykeycloak" json:"legacykeycloak" default:"false"` + SubGroups bool `mapstructure:"subgroups" json:"subgroups" default:"false"` + InferID InferredIdentityConfig `mapstructure:"inferid,omitempty" json:"inferid,omitempty"` +} + +func RegisterKeycloakERS(config config.ServiceConfig, logger *logger.Logger) (*EntityResolutionServiceV2, serviceregistry.HandlerServer) { + var inputIdpConfig Config + if err := mapstructure.Decode(config, &inputIdpConfig); err != nil { + panic(err) + } + logger.Debug("entity_resolution configuration", "config", inputIdpConfig) + keycloakSVC := &EntityResolutionServiceV2{idpConfig: inputIdpConfig, logger: logger} + return keycloakSVC, nil +} + +func (s EntityResolutionServiceV2) ResolveEntities(ctx context.Context, req *connect.Request[entityresolutionV2.ResolveEntitiesRequest]) (*connect.Response[entityresolutionV2.ResolveEntitiesResponse], error) { + ctx, span := s.Tracer.Start(ctx, "ResolveEntities") + defer span.End() + + resp, err := EntityResolution(ctx, req.Msg, s.idpConfig, s.logger) + return connect.NewResponse(&resp), err +} + +func (s EntityResolutionServiceV2) CreateEntityChainsFromTokens(ctx context.Context, req *connect.Request[entityresolutionV2.CreateEntityChainsFromTokensRequest]) (*connect.Response[entityresolutionV2.CreateEntityChainsFromTokensResponse], error) { + ctx, span := s.Tracer.Start(ctx, "CreateEntityChainsFromTokens") + defer span.End() + + resp, err := CreateEntityChainsFromTokens(ctx, req.Msg, s.idpConfig, s.logger) + return connect.NewResponse(&resp), err +} + +func (c Config) LogValue() slog.Value { + return slog.GroupValue( + slog.String("url", c.URL), + slog.String("realm", c.Realm), + slog.String("clientid", c.ClientID), + slog.String("clientsecret", "[REDACTED]"), + slog.Bool("legacykeycloak", c.LegacyKeycloak), + slog.Bool("subgroups", c.SubGroups), + slog.Any("inferid", c.InferID), + ) +} + +type InferredIdentityConfig struct { + From EntityImpliedFrom `mapstructure:"from,omitempty" json:"from,omitempty"` +} + +type EntityImpliedFrom struct { + ClientID bool `mapstructure:"clientid,omitempty" json:"clientid,omitempty"` + Email bool `mapstructure:"email,omitempty" json:"email,omitempty"` + Username bool `mapstructure:"username,omitempty" json:"username,omitempty"` +} + +type Connector struct { + token *gocloak.JWT + client *gocloak.GoCloak +} + +func CreateEntityChainsFromTokens( + ctx context.Context, + req *entityresolutionV2.CreateEntityChainsFromTokensRequest, + kcConfig Config, + logger *logger.Logger, +) (entityresolutionV2.CreateEntityChainsFromTokensResponse, error) { + entityChains := []*entity.EntityChain{} + // for each token in the tokens form an entity chain + for _, tok := range req.GetTokens() { + entities, err := getEntitiesFromToken(ctx, kcConfig, tok.GetJwt(), logger) + if err != nil { + return entityresolutionV2.CreateEntityChainsFromTokensResponse{}, err + } + entityChains = append(entityChains, &entity.EntityChain{EphemeralId: tok.GetEphemeralId(), Entities: entities}) + } + + return entityresolutionV2.CreateEntityChainsFromTokensResponse{EntityChains: entityChains}, nil +} + +func EntityResolution(ctx context.Context, + req *entityresolutionV2.ResolveEntitiesRequest, kcConfig Config, logger *logger.Logger, +) (entityresolutionV2.ResolveEntitiesResponse, error) { + connector, err := getKCClient(ctx, kcConfig, logger) + if err != nil { + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + payload := req.GetEntities() + + var resolvedEntities []*entityresolutionV2.EntityRepresentation + + for idx, ident := range payload { + logger.Debug("lookup", "entity", ident.GetEntityType()) + var keycloakEntities []*gocloak.User + var getUserParams gocloak.GetUsersParams + exactMatch := true + switch ident.GetEntityType().(type) { + case *entity.Entity_ClientId: + logger.Debug("looking up", slog.Any("type", ident.GetEntityType()), slog.String("client_id", ident.GetClientId())) + clientID := ident.GetClientId() + clients, err := connector.client.GetClients(ctx, connector.token.AccessToken, kcConfig.Realm, gocloak.GetClientsParams{ + ClientID: &clientID, + }) + if err != nil { + logger.Error("error getting client info", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrGetRetrievalFailed) + } + var jsonEntities []*structpb.Struct + for _, client := range clients { + json, err := typeToGenericJSONMap(client, logger) + if err != nil { + logger.Error("error serializing entity representation!", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + mystruct, structErr := structpb.NewStruct(json) + if structErr != nil { + logger.Error("error making struct!", slog.String("error", structErr.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + jsonEntities = append(jsonEntities, mystruct) + } + if len(clients) == 0 && kcConfig.InferID.From.ClientID { + // convert entity to json + entityStruct, err := entityToStructPb(ident) + if err != nil { + logger.Error("unable to make entity struct", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + jsonEntities = append(jsonEntities, entityStruct) + } + // make sure the id field is populated + originialID := ident.GetEphemeralId() + if originialID == "" { + originialID = auth.EntityIDPrefix + strconv.Itoa(idx) + } + resolvedEntities = append( + resolvedEntities, + &entityresolutionV2.EntityRepresentation{ + OriginalId: originialID, + AdditionalProps: jsonEntities, + }, + ) + continue + case *entity.Entity_EmailAddress: + getUserParams = gocloak.GetUsersParams{Email: func() *string { t := ident.GetEmailAddress(); return &t }(), Exact: &exactMatch} + case *entity.Entity_UserName: + getUserParams = gocloak.GetUsersParams{Username: func() *string { t := ident.GetUserName(); return &t }(), Exact: &exactMatch} + } + + var jsonEntities []*structpb.Struct + users, err := connector.client.GetUsers(ctx, connector.token.AccessToken, kcConfig.Realm, getUserParams) + switch { + case err != nil: + logger.Error(err.Error()) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrGetRetrievalFailed) + case len(users) == 1: + user := users[0] + logger.Debug("user found", slog.String("user", *user.ID), slog.String("entity", ident.String())) + logger.Debug("user", slog.Any("details", user)) + logger.Debug("user", slog.Any("attributes", user.Attributes)) + keycloakEntities = append(keycloakEntities, user) + default: + logger.Error("no user found for", slog.Any("entity", ident)) + if ident.GetEmailAddress() != "" { //nolint:nestif // this case has many possible outcomes to handle + // try by group + groups, groupErr := connector.client.GetGroups( + ctx, + connector.token.AccessToken, + kcConfig.Realm, + gocloak.GetGroupsParams{Search: func() *string { t := ident.GetEmailAddress(); return &t }()}, + ) + switch { + case groupErr != nil: + logger.Error("error getting group", slog.String("group", groupErr.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrGetRetrievalFailed) + case len(groups) == 1: + logger.Info("group found for", slog.String("entity", ident.String())) + group := groups[0] + expandedRepresentations, exErr := expandGroup(ctx, *group.ID, connector, &kcConfig, logger) + if exErr != nil { + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeNotFound, ErrNotFound) + } + keycloakEntities = expandedRepresentations + default: + logger.Error("no group found for", slog.String("entity", ident.String())) + var entityNotFoundErr entityresolutionV2.EntityNotFoundError + switch ident.GetEntityType().(type) { + case *entity.Entity_EmailAddress: + entityNotFoundErr = entityresolutionV2.EntityNotFoundError{Code: int32(connect.CodeNotFound), Message: ErrGetRetrievalFailed.Error(), Entity: ident.GetEmailAddress()} + case *entity.Entity_UserName: + entityNotFoundErr = entityresolutionV2.EntityNotFoundError{Code: int32(connect.CodeNotFound), Message: ErrGetRetrievalFailed.Error(), Entity: ident.GetUserName()} + // case "": + // return &entityresolutionV2.IdpPluginResponse{}, + // status.Error(codes.InvalidArgument, db.ErrTextNotFound) + default: + logger.Error("unsupported/unknown type for", slog.String("entity", ident.String())) + entityNotFoundErr = entityresolutionV2.EntityNotFoundError{Code: int32(codes.NotFound), Message: ErrGetRetrievalFailed.Error(), Entity: ident.String()} + } + logger.Error(entityNotFoundErr.String()) + if kcConfig.InferID.From.Email || kcConfig.InferID.From.Username { + // user not found -- add json entity to resp instead + entityStruct, err := entityToStructPb(ident) + if err != nil { + logger.Error("unable to make entity struct from email or username", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + jsonEntities = append(jsonEntities, entityStruct) + } else { + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.Code(entityNotFoundErr.GetCode()), ErrGetRetrievalFailed) + } + } + } else if ident.GetUserName() != "" { + if kcConfig.InferID.From.Username { + // user not found -- add json entity to resp instead + entityStruct, err := entityToStructPb(ident) + if err != nil { + logger.Error("unable to make entity struct from username", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + jsonEntities = append(jsonEntities, entityStruct) + } else { + entityNotFoundErr := entityresolutionV2.EntityNotFoundError{Code: int32(codes.NotFound), Message: ErrGetRetrievalFailed.Error(), Entity: ident.GetUserName()} + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.Code(entityNotFoundErr.GetCode()), ErrGetRetrievalFailed) + } + } + } + + for _, er := range keycloakEntities { + json, err := typeToGenericJSONMap(er, logger) + if err != nil { + logger.Error("error serializing entity representation!", slog.String("error", err.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + mystruct, structErr := structpb.NewStruct(json) + if structErr != nil { + logger.Error("error making struct!", slog.String("error", structErr.Error())) + return entityresolutionV2.ResolveEntitiesResponse{}, + connect.NewError(connect.CodeInternal, ErrCreationFailed) + } + jsonEntities = append(jsonEntities, mystruct) + } + // make sure the id field is populated + originialID := ident.GetEphemeralId() + if originialID == "" { + originialID = auth.EntityIDPrefix + strconv.Itoa(idx) + } + resolvedEntities = append( + resolvedEntities, + &entityresolutionV2.EntityRepresentation{ + OriginalId: originialID, + AdditionalProps: jsonEntities, + }, + ) + logger.Debug("entities", slog.Any("resolved", resolvedEntities)) + } + + return entityresolutionV2.ResolveEntitiesResponse{ + EntityRepresentations: resolvedEntities, + }, nil +} + +func typeToGenericJSONMap[Marshalable any](inputStruct Marshalable, logger *logger.Logger) (map[string]interface{}, error) { + // For now, since we dont' know the "shape" of the entity/user record or representation we will get from a specific entity store, + tmpDoc, err := json.Marshal(inputStruct) + if err != nil { + logger.Error("error marshalling input type!", slog.String("error", err.Error())) + return nil, err + } + + var genericMap map[string]interface{} + err = json.Unmarshal(tmpDoc, &genericMap) + if err != nil { + logger.Error("could not deserialize generic entitlement context JSON input document!", slog.String("error", err.Error())) + return nil, err + } + + return genericMap, nil +} + +func getKCClient(ctx context.Context, kcConfig Config, logger *logger.Logger) (*Connector, error) { + var client *gocloak.GoCloak + if kcConfig.LegacyKeycloak { + logger.Warn("using legacy connection mode for Keycloak < 17.x.x") + client = gocloak.NewClient(kcConfig.URL) + } else { + client = gocloak.NewClient(kcConfig.URL, gocloak.SetAuthAdminRealms("admin/realms"), gocloak.SetAuthRealms("realms")) + } + // If needed, ability to disable tls checks for testing + // restyClient := client.RestyClient() + // restyClient.SetDebug(true) + // restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) + // client.SetRestyClient(restyClient) + + // For debugging + // logger.Debug(kcConfig.ClientID) + // logger.Debug(kcConfig.ClientSecret) + // logger.Debug(kcConfig.URL) + // logger.Debug(kcConfig.Realm) + token, err := client.LoginClient(ctx, kcConfig.ClientID, kcConfig.ClientSecret, kcConfig.Realm) + if err != nil { + logger.Error("error connecting to keycloak!", slog.String("error", err.Error())) + return nil, err + } + keycloakConnector := Connector{token: token, client: client} + + return &keycloakConnector, nil +} + +func expandGroup(ctx context.Context, groupID string, kcConnector *Connector, kcConfig *Config, logger *logger.Logger) ([]*gocloak.User, error) { + logger.Info("expanding group", slog.String("groupID", groupID)) + var entityRepresentations []*gocloak.User + + grp, err := kcConnector.client.GetGroup(ctx, kcConnector.token.AccessToken, kcConfig.Realm, groupID) + if err == nil { + grpMembers, memberErr := kcConnector.client.GetGroupMembers(ctx, kcConnector.token.AccessToken, kcConfig.Realm, + *grp.ID, gocloak.GetGroupsParams{}) + if memberErr == nil { + logger.Debug("adding members", slog.Int("amount", len(grpMembers)), slog.String("from group", *grp.Name)) + for i := 0; i < len(grpMembers); i++ { + user := grpMembers[i] + entityRepresentations = append(entityRepresentations, user) + } + } else { + logger.Error("error getting group members", slog.String("error", memberErr.Error())) + } + } else { + logger.Error("error getting group", slog.String("error", err.Error())) + return nil, err + } + return entityRepresentations, nil +} + +func getEntitiesFromToken(ctx context.Context, kcConfig Config, jwtString string, logger *logger.Logger) ([]*entity.Entity, error) { + token, err := jwt.ParseString(jwtString, jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, errors.New("error parsing jwt " + err.Error()) + } + claims, err := token.AsMap(context.Background()) ///nolint:contextcheck // Do not want to include keys from context in map + if err != nil { + return nil, errors.New("error getting claims from jwt") + } + entities := []*entity.Entity{} + entityID := 0 + + // extract azp + extractedValue, okExtract := claims[ClientJwtSelector] + if !okExtract { + return nil, errors.New("error extracting selector " + ClientJwtSelector + " from jwt") + } + extractedValueCasted, okCast := extractedValue.(string) + if !okCast { + return nil, errors.New("error casting extracted value to string") + } + entities = append(entities, &entity.Entity{ + EntityType: &entity.Entity_ClientId{ClientId: extractedValueCasted}, + EphemeralId: fmt.Sprintf("jwtentity-%d-clientid-%s", entityID, extractedValueCasted), + Category: entity.Entity_CATEGORY_ENVIRONMENT, + }) + entityID++ + + extractedValueUsername, okExp := claims[UsernameJwtSelector] + if !okExp { + return nil, errors.New("error extracting selector " + UsernameJwtSelector + " from jwt") + } + extractedValueUsernameCasted, okUsernameCast := extractedValueUsername.(string) + if !okUsernameCast { + return nil, errors.New("error casting extracted value to string") + } + + // double check for service account + if strings.HasPrefix(extractedValueUsernameCasted, serviceAccountUsernamePrefix) { + clientid, err := getServiceAccountClient(ctx, extractedValueUsernameCasted, kcConfig, logger) + if err != nil { + return nil, err + } + if clientid != "" { + entities = append(entities, &entity.Entity{ + EntityType: &entity.Entity_ClientId{ClientId: clientid}, + EphemeralId: fmt.Sprintf("jwtentity-%d-clientid-%s", entityID, clientid), + Category: entity.Entity_CATEGORY_SUBJECT, + }) + } else { + // if the returned clientId is empty, no client found, its not a serive account proceed with username + entities = append(entities, &entity.Entity{ + EntityType: &entity.Entity_UserName{UserName: extractedValueUsernameCasted}, + EphemeralId: fmt.Sprintf("jwtentity-%d-username-%s", entityID, extractedValueUsernameCasted), + Category: entity.Entity_CATEGORY_SUBJECT, + }) + } + } else { + entities = append(entities, &entity.Entity{ + EntityType: &entity.Entity_UserName{UserName: extractedValueUsernameCasted}, + EphemeralId: fmt.Sprintf("jwtentity-%d-username-%s", entityID, extractedValueUsernameCasted), + Category: entity.Entity_CATEGORY_SUBJECT, + }) + } + + return entities, nil +} + +func getServiceAccountClient(ctx context.Context, username string, kcConfig Config, logger *logger.Logger) (string, error) { + connector, err := getKCClient(ctx, kcConfig, logger) + if err != nil { + return "", err + } + expectedClientName := strings.TrimPrefix(username, serviceAccountUsernamePrefix) + + clients, err := connector.client.GetClients(ctx, connector.token.AccessToken, kcConfig.Realm, gocloak.GetClientsParams{ + ClientID: &expectedClientName, + }) + switch { + case err != nil: + logger.Error(err.Error()) + return "", err + case len(clients) == 1: + client := clients[0] + logger.Debug("client found", slog.String("client", *client.ClientID)) + return *client.ClientID, nil + case len(clients) > 1: + logger.Error("more than one client found for ", slog.String("clientid", expectedClientName)) + default: + logger.Debug("no client found, likely not a service account", slog.String("clientid", expectedClientName)) + } + + return "", nil +} + +func entityToStructPb(ident *entity.Entity) (*structpb.Struct, error) { + entityBytes, err := protojson.Marshal(ident) + if err != nil { + return nil, err + } + var entityStruct structpb.Struct + err = entityStruct.UnmarshalJSON(entityBytes) + if err != nil { + return nil, err + } + return &entityStruct, nil +} diff --git a/service/entityresolution/keycloak/v2/entity_resolution_test.go b/service/entityresolution/keycloak/v2/entity_resolution_test.go new file mode 100644 index 0000000000..d7bd502714 --- /dev/null +++ b/service/entityresolution/keycloak/v2/entity_resolution_test.go @@ -0,0 +1,587 @@ +package keycloak_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "connectrpc.com/connect" + "github.com/opentdf/platform/protocol/go/entity" + entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + keycloak "github.com/opentdf/platform/service/entityresolution/keycloak/v2" + "github.com/opentdf/platform/service/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +const tokenResp string = ` +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "token_type": "Bearer", + "expires_in": 3600, +}` + +const byEmailBobResp = `[ +{"id": "bobid", "username":"bob.smith"} +] +` + +const byEmailAliceResp = `[ +{"id": "aliceid", "username":"alice.smith"} +] +` + +const byUsernameBobResp = `[ +{"id": "bobid", "username":"bob.smith"} +]` + +const byUsernameAliceResp = `[ +{"id": "aliceid", "username":"alice.smith"} +]` + +const groupSubmemberResp = `[ + {"id": "bobid", "username":"bob.smith"}, + {"id": "aliceid", "username":"alice.smith"} +]` + +const groupResp = `{ + "id": "group1-uuid", + "name": "group1" +}` + +const byClientIDOpentdfSdkResp = `[ +{"id": "opentdfsdkclient", "clientId":"opentdf-sdk"} +] +` + +const byClientIDTDFEntityResResp = `[ +{"id": "tdf-entity-resolution", "clientId":"tdf-entity-resolution"} +] +` + +const ( + clientCredentialsJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTE2MDQsImlhdCI6MTcxNTA5MTMwNCwianRpIjoiMTE3MTYzMjYtNWQyNS00MjlmLWFjMDItNmU0MjE2OWFjMGJhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2F1dGgvcmVhbG1zL29wZW50ZGYiLCJhdWQiOlsiaHR0cDovL2xvY2FsaG9zdDo4ODg4IiwicmVhbG0tbWFuYWdlbWVudCIsImFjY291bnQiXSwic3ViIjoiOTljOWVlZDItOTM1Ni00ZjE2LWIwODQtZTgyZDczZjViN2QyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGRmLWVudGl0eS1yZXNvbHV0aW9uIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLW9wZW50ZGYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyIsInF1ZXJ5LXVzZXJzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImNsaWVudEhvc3QiOiIxOTIuMTY4LjI0MC4xIiwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LXRkZi1lbnRpdHktcmVzb2x1dGlvbiIsImNsaWVudEFkZHJlc3MiOiIxOTIuMTY4LjI0MC4xIiwiY2xpZW50X2lkIjoidGRmLWVudGl0eS1yZXNvbHV0aW9uIn0.h29QLo-QvIc67KKqU_e1-x6G_o5YQccOyW9AthMdB7xhn9C1dBrcScytaWq1RfETPmnM8MXGezqN4OpXrYr-zbkHhq9ha0Ib-M1VJXNgA5sbgKW9JxGQyudmYPgn4fimDCJtAsXo7C-e3mYNm6DJS0zhGQ3msmjLTcHmIPzWlj7VjtPgKhYV75b7yr_yZNBdHjf3EZqfynU2sL8bKa1w7DYDNQve7ThtD4MeKLiuOQHa3_23dECs_ptvPVks7pLGgRKfgGHBC-KQuopjtxIhwkz2vOWRzugDl0aBJMHfwBajYhgZ2YRlV9dqSxmy8BOj4OEXuHbiyfIpY0rCRpSrGg" + passwordPubClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTE0ODAsImlhdCI6MTcxNTA5MTE4MCwianRpIjoiZmI5MmM2MTAtYmI0OC00ZDgyLTljZGQtOWFhZjllNzEyNzc3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2F1dGgvcmVhbG1zL29wZW50ZGYiLCJhdWQiOlsiaHR0cDovL2xvY2FsaG9zdDo4ODg4IiwidGRmLWVudGl0eS1yZXNvbHV0aW9uIiwicmVhbG0tbWFuYWdlbWVudCIsImFjY291bnQiXSwic3ViIjoiMmU2YzE1ODAtY2ZkMy00M2FiLWIxNzMtZjZjM2JmOGZmNGUyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGRmLWVudGl0eS1yZXNvbHV0aW9uLXB1YmxpYyIsInNlc3Npb25fc3RhdGUiOiIzN2E3YjdiOS0xZmNlLTQxMmYtOTI1OS1lYzUxMTY3MGVhMGYiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIi8qIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvcGVudGRmLW9yZy1hZG1pbiIsImRlZmF1bHQtcm9sZXMtb3BlbnRkZiIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJ0ZGYtZW50aXR5LXJlc29sdXRpb24iOnsicm9sZXMiOlsiZW50aXR5LXJlc29sdXRpb24tdGVzdC1yb2xlIl19LCJyZWFsbS1tYW5hZ2VtZW50Ijp7InJvbGVzIjpbInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJxdWVyeS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIiwicXVlcnktdXNlcnMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsInNpZCI6IjM3YTdiN2I5LTFmY2UtNDEyZi05MjU5LWVjNTExNjcwZWEwZiIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6InNhbXBsZSB1c2VyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoic2FtcGxlLXVzZXIiLCJnaXZlbl9uYW1lIjoic2FtcGxlIiwiZmFtaWx5X25hbWUiOiJ1c2VyIiwiZW1haWwiOiJzYW1wbGV1c2VyQHNhbXBsZS5jb20ifQ.Gd_OvPNY7UfY7sBKh55TcvWQHmAkYZ2Jb2VyK1lYgse9EBEa_y3uoepZYrGMGkmYdwApg4eauQjxzT_BZYVBc7u9ch3HY_IUuSh3A6FkDDXZIziByP63FYiI4vKTp0w7e2-oYAdaUTDJ1Y50-l_VvRWjdc4fqi-OKH4t8D1rlq0GJ-P7uOl44Ta43YdBMuXI146-eLqx_zLIC49Pg5Y7MD_Lv23QfGTHTP47ckUQueXoGegNLQNE9nPTuD6lNzHD5_MOqse4IKzoWVs_hs4S8SqVxVlN_ZWXkcGhPllfQtf1qxLyFm51eYH3LGxqyNbGr4nQc8djPV0yWqOTrg8IYQ" + passwordPrivClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTE0MjMsImlhdCI6MTcxNTA5MTEyMywianRpIjoiMTNhNDljZmQtOGRiZC00NTA2LTk1NGMtZWFmZGRkNGE4ZTdjIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2F1dGgvcmVhbG1zL29wZW50ZGYiLCJhdWQiOlsiaHR0cDovL2xvY2FsaG9zdDo4ODg4IiwicmVhbG0tbWFuYWdlbWVudCIsImFjY291bnQiXSwic3ViIjoiMmU2YzE1ODAtY2ZkMy00M2FiLWIxNzMtZjZjM2JmOGZmNGUyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGRmLWVudGl0eS1yZXNvbHV0aW9uIiwic2Vzc2lvbl9zdGF0ZSI6ImNmZjYwZDZmLWI2M2MtNDBhYy1hYjI3LWFjZmU4MjY5OWQyYSIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib3BlbnRkZi1vcmctYWRtaW4iLCJkZWZhdWx0LXJvbGVzLW9wZW50ZGYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGRmLWVudGl0eS1yZXNvbHV0aW9uIjp7InJvbGVzIjpbImVudGl0eS1yZXNvbHV0aW9uLXRlc3Qtcm9sZSJdfSwicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyIsInF1ZXJ5LXVzZXJzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InByb2ZpbGUgZW1haWwiLCJzaWQiOiJjZmY2MGQ2Zi1iNjNjLTQwYWMtYWIyNy1hY2ZlODI2OTlkMmEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJzYW1wbGUgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6InNhbXBsZS11c2VyIiwiZ2l2ZW5fbmFtZSI6InNhbXBsZSIsImZhbWlseV9uYW1lIjoidXNlciIsImVtYWlsIjoic2FtcGxldXNlckBzYW1wbGUuY29tIn0.JFWKf7GZq1f8raP3Jm6rszpwPCh0JnaeZcHyC_AwcsNS6sJ9_qSY9wrbyHmvV9KIGMIPv23fymADZXb0ng7maeAv9NR34KiJqnKbmPeeWwL0cPoGOGOUICL6H5x1iTw7XaMDN2WQRrBRFuUkudybEF8n6fEGsAvcsXViaHjYwJyIEYCnHKPzuTvM1RjyGFsERpFXKls4UB_KhMBEonr4JOskupmX1pADBuicTNx_4whnd6ZDfiF5SSBohFV1ikwFOXK-qZ7znQfE-RJ-jV1CXBgEK8O66TMbMw9MbasS25xKoO0mH1_Ohf9niSXsY02o2qjGFZA9sWRk7K7pNgsxUw" + authPubClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTI2NDcsImlhdCI6MTcxNTA5MjM0NywiYXV0aF90aW1lIjoxNzE1MDkyMzQ3LCJqdGkiOiI3NzkwZmVhNC1hNzcyLTRhZTMtOTcyMi0yMTU2MmQyZGM5YmYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYXV0aC9yZWFsbXMvb3BlbnRkZiIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0Ojg4ODgiLCJ0ZGYtZW50aXR5LXJlc29sdXRpb24iLCJyZWFsbS1tYW5hZ2VtZW50IiwiYWNjb3VudCJdLCJzdWIiOiIyZTZjMTU4MC1jZmQzLTQzYWItYjE3My1mNmMzYmY4ZmY0ZTIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0ZGYtZW50aXR5LXJlc29sdXRpb24tcHVibGljIiwic2Vzc2lvbl9zdGF0ZSI6ImFmNmViOTRiLWU4ZDQtNDNlYi1hYzYwLTI3YmZiYjNiOTQxZSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOltdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib3BlbnRkZi1vcmctYWRtaW4iLCJkZWZhdWx0LXJvbGVzLW9wZW50ZGYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGRmLWVudGl0eS1yZXNvbHV0aW9uIjp7InJvbGVzIjpbImVudGl0eS1yZXNvbHV0aW9uLXRlc3Qtcm9sZSJdfSwicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyIsInF1ZXJ5LXVzZXJzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwic2lkIjoiYWY2ZWI5NGItZThkNC00M2ViLWFjNjAtMjdiZmJiM2I5NDFlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoic2FtcGxlIHVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzYW1wbGUtdXNlciIsImdpdmVuX25hbWUiOiJzYW1wbGUiLCJmYW1pbHlfbmFtZSI6InVzZXIiLCJlbWFpbCI6InNhbXBsZXVzZXJAc2FtcGxlLmNvbSJ9.BfCX_fq2Q6cv5qb6TWzyCNiJf1vFw_iA4UTKr21ahUiNItNSW5ozET2jSLwKNAuMdBNrooU--OV1NniDcviIAMReaAEgjmAyBz6OSwAh3SmxzIU9Zy7f4V032FLDcVVoJ7CefItfBNu7WnWFGS7CYahNX_M2a6LXKhk7WRO4gQ2Ig11gtODlAP8jwLLAMU4_H9mVHD3LXd-IeOsnA8ZuBCq1DeFcn5T9tNZEGe0_21lp8spxoub0MRl-vYbgxEIoaeqxoSipb2hOjF7h0h1uaNhZT4m5ynHdd5yfspD8XjjjwlbXQn9Z8vrZUQQS6HLAi2pJIFNEoYxQk9lHal6VUA" + authPrivClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTI3NDAsImlhdCI6MTcxNTA5MjQ0MCwiYXV0aF90aW1lIjoxNzE1MDkyNDQwLCJqdGkiOiIzZjk2ZDhjNC0yMjRkLTQyNjAtYmVkMy1lOGY2N2IwYTJjM2EiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYXV0aC9yZWFsbXMvb3BlbnRkZiIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0Ojg4ODgiLCJyZWFsbS1tYW5hZ2VtZW50IiwiYWNjb3VudCJdLCJzdWIiOiIyZTZjMTU4MC1jZmQzLTQzYWItYjE3My1mNmMzYmY4ZmY0ZTIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0ZGYtZW50aXR5LXJlc29sdXRpb24iLCJzZXNzaW9uX3N0YXRlIjoiZDI1ZjhhZTMtYzE0Yy00ZWFmLTkzOWMtZjhlNGIyYmE1NDY3IiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6W10sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvcGVudGRmLW9yZy1hZG1pbiIsImRlZmF1bHQtcm9sZXMtb3BlbnRkZiIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJ0ZGYtZW50aXR5LXJlc29sdXRpb24iOnsicm9sZXMiOlsiZW50aXR5LXJlc29sdXRpb24tdGVzdC1yb2xlIl19LCJyZWFsbS1tYW5hZ2VtZW50Ijp7InJvbGVzIjpbInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJxdWVyeS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIiwicXVlcnktdXNlcnMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiJkMjVmOGFlMy1jMTRjLTRlYWYtOTM5Yy1mOGU0YjJiYTU0NjciLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJzYW1wbGUgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6InNhbXBsZS11c2VyIiwiZ2l2ZW5fbmFtZSI6InNhbXBsZSIsImZhbWlseV9uYW1lIjoidXNlciIsImVtYWlsIjoic2FtcGxldXNlckBzYW1wbGUuY29tIn0.JphgIUbUeEEd25-Ji0o6_pcWvLzcQasCfr1z8WRt3xb1QrkN_d0_rmIpOJ9drhp8LjJPQRhFVxEU2TnAlYJ225IjPYolCrnEtKWsBbPJH9dP0cIJlilYplN4RZbmI9VbF578zAgVAs40n8aalNyxqYbPq_JViHDl_ufl4VEQ4Entzlp980I8whx3kfTygu0Yfl4eHLghPGt4LNPUmfeOIy8NKHbhmjHwKufrTmd0NV07cAOMUWl1NAF_4QWqmSqAY0SIcamwE7YlpuImzhj5PQH9tlyJMLr5m-k8CgKRfhpQ0H9cfVGUzWGG2A-lcNvxNmsk1kobmfHczjw13ajLKg" + implicitPrivClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUwOTM4MzgsImlhdCI6MTcxNTA5MjkzOCwiYXV0aF90aW1lIjoxNzE1MDkyOTM4LCJqdGkiOiI0ZWIzY2I1OS05ZDRhLTQwNjctYmI0YS1iMjNjNDVhMDIyYTIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYXV0aC9yZWFsbXMvb3BlbnRkZiIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0Ojg4ODgiLCJyZWFsbS1tYW5hZ2VtZW50IiwiYWNjb3VudCJdLCJzdWIiOiIyZTZjMTU4MC1jZmQzLTQzYWItYjE3My1mNmMzYmY4ZmY0ZTIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0ZGYtZW50aXR5LXJlc29sdXRpb24iLCJzZXNzaW9uX3N0YXRlIjoiYmE1OWFmOTgtNmE3YS00YjRhLTliOTItODU1M2ZkM2EwMTNjIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6W10sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvcGVudGRmLW9yZy1hZG1pbiIsImRlZmF1bHQtcm9sZXMtb3BlbnRkZiIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJ0ZGYtZW50aXR5LXJlc29sdXRpb24iOnsicm9sZXMiOlsiZW50aXR5LXJlc29sdXRpb24tdGVzdC1yb2xlIl19LCJyZWFsbS1tYW5hZ2VtZW50Ijp7InJvbGVzIjpbInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJxdWVyeS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIiwicXVlcnktdXNlcnMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiJiYTU5YWY5OC02YTdhLTRiNGEtOWI5Mi04NTUzZmQzYTAxM2MiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJzYW1wbGUgdXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6InNhbXBsZS11c2VyIiwiZ2l2ZW5fbmFtZSI6InNhbXBsZSIsImZhbWlseV9uYW1lIjoidXNlciIsImVtYWlsIjoic2FtcGxldXNlckBzYW1wbGUuY29tIn0.KQasyQ-f9KBRWRDXg4NikiHlragVil1nlYTQ8czPKku_uncpHZb7PMJhyPrwy72mwC10EJMpBIWGDVRfRjBRHxdzIbjozcGg3vusX748NMiDSlF4KoB5Fz-qExszcszEP5Qm_fvMRFcW7m9RPW9St1aaHcjAOW5Vee9ACJI56YffgqrTn1xp7ha2Z2X8d_NJfJOFdP3cqgxjR7DV5RezkDLRPfxHwJLk3anavSuDScXIO1w1C6AlTUQFVQUEX0DKZIt-RbzKcd6HWBfyDvHUSlfodEI_diWQIL1hEfrBXV6ThuhTqhrghHyIbb2e-zoC20arjMAK0Tr7hMAY4acxgQ" + implicitPubClientJwt = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0OXRmSjByRUo4c0YzUjJ3Yi05eENHVXhYUEQ4RTZldmNsRG1hZ05EM3lBIn0.eyJleHAiOjE3MTUxNTE0MTMsImlhdCI6MTcxNTE1MDUxMywiYXV0aF90aW1lIjoxNzE1MTUwNTEzLCJqdGkiOiJlYTRmOGZiYS01ZjljLTRiMzQtYmU1ZC1jNTk2ZGI4YzNlYzkiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYXV0aC9yZWFsbXMvb3BlbnRkZiIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0Ojg4ODgiLCJ0ZGYtZW50aXR5LXJlc29sdXRpb24iLCJyZWFsbS1tYW5hZ2VtZW50IiwiYWNjb3VudCJdLCJzdWIiOiIyZTZjMTU4MC1jZmQzLTQzYWItYjE3My1mNmMzYmY4ZmY0ZTIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0ZGYtZW50aXR5LXJlc29sdXRpb24tcHVibGljIiwic2Vzc2lvbl9zdGF0ZSI6ImRlM2U2ZDc1LTI3ODItNDg4NS1iYzU4LTU0MmJmYzEzNWNkNSIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOltdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib3BlbnRkZi1vcmctYWRtaW4iLCJkZWZhdWx0LXJvbGVzLW9wZW50ZGYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsidGRmLWVudGl0eS1yZXNvbHV0aW9uIjp7InJvbGVzIjpbImVudGl0eS1yZXNvbHV0aW9uLXRlc3Qtcm9sZSJdfSwicmVhbG0tbWFuYWdlbWVudCI6eyJyb2xlcyI6WyJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyIsInF1ZXJ5LXVzZXJzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwic2lkIjoiZGUzZTZkNzUtMjc4Mi00ODg1LWJjNTgtNTQyYmZjMTM1Y2Q1IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoic2FtcGxlIHVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzYW1wbGUtdXNlciIsImdpdmVuX25hbWUiOiJzYW1wbGUiLCJmYW1pbHlfbmFtZSI6InVzZXIiLCJlbWFpbCI6InNhbXBsZXVzZXJAc2FtcGxlLmNvbSJ9.jH60V3ZkuiN6cuEmbRTspnxyOvQs_wNkgoBw9IEZ8E8yGzXDayouduxEd_O-DG6vjT4KPDxGC2lA0V4i-ke7KChkZhRYLkaSuqt2hTlKoLXotepJPq8GBlXhWjCmFMqaXEB8lMAlAEoCT7CWmg03eTBGzwynj0S4rjMuOj6TLf3HIIN0DP7bgtG9uIc0Ah_mTVJ4L6Y5yjv6LC9bMZ7YNpUIkFn-CZTudquxHkLYgxHgaRAfELBvmS5xn0pTrpIfZSdYQK7hGhjhm9fUg4J06Pg6QW-xZe1U7awyNl7pOeeGQ2lVTo1CWrAlOz9lAmzKzAwQakEOMXFxAjJeHsXTWg" + tokenExchangeJwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImE1NThkYzg0NzYzNDVjY2QyZWFhNjEzNjg4YmI2YTNkIn0.eyJleHAiOjE3MTU3OTA5MTAsImlhdCI6MTcxNTc5MDYxMCwianRpIjoiNjEyOTI2NzQtMDhmOS00ZmQ1LTk3Y2MtZDg3M2RhODRkZjllIiwiaXNzIjoiaHR0cHM6Ly9sb2NhbC1kc3AudmlydHJ1LmNvbTo4NDQzL2F1dGgvcmVhbG1zL29wZW50ZGYiLCJhdWQiOlsiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiYWNjb3VudCIsIm9wZW50ZGYtc2RrIl0sInN1YiI6ImU2ZWI0YWU1LThjMDUtNDI3NC04ZmExLTFmMGY1ZmJjY2JkZiIsInR5cCI6IkJlYXJlciIsImF6cCI6Im9wZW50ZGYiLCJzZXNzaW9uX3N0YXRlIjoiZTQ0YzMxNWMtNjk5Yy00NGFkLTk2NDUtNmRkMmIyMjgzN2JlIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvcGVudGRmLXJlYWRvbmx5IiwiZGVmYXVsdC1yb2xlcy1vcGVudGRmIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJzaWQiOiJlNDRjMzE1Yy02OTljLTQ0YWQtOTY0NS02ZGQyYjIyODM3YmUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InNlcnZpY2UtYWNjb3VudC1vcGVudGRmLXNkayJ9.dmAulsUNfdPXVyWmVPsbGqaztshyHTD-m2hh1l2hmhwuNISJZjON0e1kXNxYXRLABr_PJzIpGYQCXz98yxOyiw" +) + +func testConfig(server *httptest.Server) keycloak.Config { + return keycloak.Config{ + URL: server.URL, + ClientID: "c1", + ClientSecret: "cs", + Realm: "tdf", + LegacyKeycloak: false, + } +} + +func testConfigInferID(server *httptest.Server) keycloak.Config { + return keycloak.Config{ + URL: server.URL, + ClientID: "c1", + ClientSecret: "cs", + Realm: "tdf", + LegacyKeycloak: false, + InferID: keycloak.InferredIdentityConfig{ + From: keycloak.EntityImpliedFrom{ + Email: true, + ClientID: true, + }, + }, + } +} + +func testServerResp(t *testing.T, w http.ResponseWriter, r *http.Request, k string, reqRespMap map[string]string) { + i, ok := reqRespMap[k] + if ok == true { + w.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(w, i) + if err != nil { + t.Error(err) + } + } else { + t.Errorf("UnExpected Request, got: %s", r.URL.Path) + } +} + +func testServer(t *testing.T, userSearchQueryAndResp map[string]string, groupSearchQueryAndResp map[string]string, + groupByIDAndResponse map[string]string, groupMemberQueryAndResponse map[string]string, clientsSearchQueryAndResp map[string]string, +) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/realms/tdf/protocol/openid-connect/token": + _, err := io.WriteString(w, tokenResp) + if err != nil { + t.Error(err) + } + case r.URL.Path == "/admin/realms/tdf/clients": + testServerResp(t, w, r, r.URL.RawQuery, clientsSearchQueryAndResp) + case r.URL.Path == "/admin/realms/tdf/users": + testServerResp(t, w, r, r.URL.RawQuery, userSearchQueryAndResp) + case r.URL.Path == "/admin/realms/tdf/groups" && groupSearchQueryAndResp != nil: + testServerResp(t, w, r, r.URL.RawQuery, groupSearchQueryAndResp) + case strings.HasPrefix(r.URL.Path, "/admin/realms/tdf/groups") && + strings.HasSuffix(r.URL.Path, "members") && groupMemberQueryAndResponse != nil: + groupID := r.URL.Path[len("/admin/realms/tdf/groups/"):strings.LastIndex(r.URL.Path, "/")] + testServerResp(t, w, r, groupID, groupMemberQueryAndResponse) + case strings.HasPrefix(r.URL.Path, "/admin/realms/tdf/groups") && groupByIDAndResponse != nil: + groupID := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + testServerResp(t, w, r, groupID, groupByIDAndResponse) + default: + t.Errorf("UnExpected Request, got: %s", r.URL.Path) + } + })) + return server +} + +func Test_KCEntityResolutionByClientId(t *testing.T) { + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_ClientId{ClientId: "opentdf"}}) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + csqr := map[string]string{ + "clientId=opentdf": byEmailBobResp, + } + server := testServer(t, nil, nil, nil, nil, csqr) + defer server.Close() + kcconfig := testConfig(server) + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + _ = json.NewEncoder(os.Stdout).Encode(&resp) + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) +} + +func Test_KCEntityResolutionByEmail(t *testing.T) { + server := testServer(t, map[string]string{ + "email=bob%40sample.org&exact=true": byEmailBobResp, + "email=alice%40sample.org&exact=true": byEmailAliceResp, + }, nil, nil, nil, nil) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_EmailAddress{EmailAddress: "bob@sample.org"}}) + validBody = append(validBody, &entity.Entity{EphemeralId: "1235", EntityType: &entity.Entity_EmailAddress{EmailAddress: "alice@sample.org"}}) + + kcconfig := testConfig(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 2) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "bobid", propMap["id"]) + + assert.Equal(t, "1235", entityRepresentations[1].GetOriginalId()) + assert.Len(t, entityRepresentations[1].GetAdditionalProps(), 1) + propMap = entityRepresentations[1].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "aliceid", propMap["id"]) +} + +func Test_KCEntityResolutionByUsername(t *testing.T) { + server := testServer(t, map[string]string{ + "exact=true&username=bob.smith": byUsernameBobResp, + "exact=true&username=alice.smith": byUsernameAliceResp, + }, nil, nil, nil, nil) + defer server.Close() + + // validBody := `{"entity_identifiers": [{"type": "username","identifier": "bob.smith"}]}` + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_UserName{UserName: "bob.smith"}}) + validBody = append(validBody, &entity.Entity{EphemeralId: "1235", EntityType: &entity.Entity_UserName{UserName: "alice.smith"}}) + + kcconfig := testConfig(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 2) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "bobid", propMap["id"]) + + assert.Equal(t, "1235", entityRepresentations[1].GetOriginalId()) + assert.Len(t, entityRepresentations[1].GetAdditionalProps(), 1) + propMap = entityRepresentations[1].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "aliceid", propMap["id"]) +} + +func Test_KCEntityResolutionByGroupEmail(t *testing.T) { + server := testServer(t, map[string]string{ + "email=group1%40sample.org&exact=true": "[]", + }, map[string]string{ + "search=group1%40sample.org": `[{"id":"group1-uuid"}]`, + }, map[string]string{ + "group1-uuid": groupResp, + }, map[string]string{ + "group1-uuid": groupSubmemberResp, + }, + nil) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "123456", EntityType: &entity.Entity_EmailAddress{EmailAddress: "group1@sample.org"}}) + + kcconfig := testConfig(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "123456", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 2) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "bobid", propMap["id"]) + propMap = entityRepresentations[0].GetAdditionalProps()[1].AsMap() + assert.Equal(t, "aliceid", propMap["id"]) +} + +func Test_KCEntityResolutionNotFoundError(t *testing.T) { + server := testServer(t, map[string]string{ + "email=random%40sample.org&exact=true": "[]", + }, map[string]string{ + "search=random%40sample.org": "[]", + }, map[string]string{ + "group1-uuid": groupResp, + }, map[string]string{ + "group1-uuid": groupSubmemberResp, + }, nil) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_EmailAddress{EmailAddress: "random@sample.org"}}) + + kcconfig := testConfig(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.Error(t, reserr) + assert.Equal(t, &entityresolutionV2.ResolveEntitiesResponse{}, &resp) + entityNotFound := entityresolutionV2.EntityNotFoundError{Code: int32(codes.NotFound), Message: keycloak.ErrGetRetrievalFailed.Error(), Entity: "random@sample.org"} + expectedError := connect.NewError(connect.Code(entityNotFound.GetCode()), keycloak.ErrGetRetrievalFailed) + assert.Equal(t, expectedError, reserr) +} + +func Test_JwtClientAndUsernameClientCredentials(t *testing.T) { + csqr := map[string]string{ + "clientId=tdf-entity-resolution": byClientIDTDFEntityResResp, + } + server := testServer(t, nil, nil, nil, nil, csqr) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: clientCredentialsJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[0].GetEntities()[1].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernamePasswordPub(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: passwordPubClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution-public", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernamePasswordPriv(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: passwordPrivClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernameAuthPub(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: authPubClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution-public", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernameAuthPriv(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: authPrivClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernameImplicitPub(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: implicitPubClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution-public", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndUsernameImplicitPriv(t *testing.T) { + server := testServer(t, nil, nil, nil, nil, nil) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: implicitPrivClientJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[0].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtClientAndClientTokenExchange(t *testing.T) { + csqr := map[string]string{ + "clientId=opentdf-sdk": byClientIDOpentdfSdkResp, + } + server := testServer(t, nil, nil, nil, nil, csqr) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: tokenExchangeJwt}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 1) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "opentdf", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "opentdf-sdk", resp.GetEntityChains()[0].GetEntities()[1].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) +} + +func Test_JwtMultiple(t *testing.T) { + csqr := map[string]string{ + "clientId=opentdf-sdk": byClientIDOpentdfSdkResp, + } + server := testServer(t, nil, nil, nil, nil, csqr) + defer server.Close() + + kcconfig := testConfig(server) + + validBody := []*entity.Token{{Jwt: tokenExchangeJwt, EphemeralId: "tok1"}, {Jwt: authPrivClientJwt, EphemeralId: "tok2"}} + + resp, reserr := keycloak.CreateEntityChainsFromTokens(t.Context(), &entityresolutionV2.CreateEntityChainsFromTokensRequest{Tokens: validBody}, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + assert.Len(t, resp.GetEntityChains(), 2) + assert.Len(t, resp.GetEntityChains()[0].GetEntities(), 2) + assert.Equal(t, "opentdf", resp.GetEntityChains()[0].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[0].GetEntities()[0].GetCategory()) + assert.Equal(t, "opentdf-sdk", resp.GetEntityChains()[0].GetEntities()[1].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[0].GetEntities()[1].GetCategory()) + + assert.Len(t, resp.GetEntityChains()[1].GetEntities(), 2) + assert.Equal(t, "tdf-entity-resolution", resp.GetEntityChains()[1].GetEntities()[0].GetClientId()) + assert.Equal(t, entity.Entity_CATEGORY_ENVIRONMENT, resp.GetEntityChains()[1].GetEntities()[0].GetCategory()) + assert.Equal(t, "sample-user", resp.GetEntityChains()[1].GetEntities()[1].GetUserName()) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, resp.GetEntityChains()[1].GetEntities()[1].GetCategory()) +} + +func Test_KCEntityResolutionNotFoundInferEmail(t *testing.T) { + server := testServer(t, map[string]string{ + "email=random%40sample.org&exact=true": "[]", + }, map[string]string{ + "search=random%40sample.org": "[]", + }, map[string]string{ + "group1-uuid": groupResp, + }, map[string]string{ + "group1-uuid": groupSubmemberResp, + }, nil) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_EmailAddress{EmailAddress: "random@sample.org"}}) + + kcconfig := testConfigInferID(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "random@sample.org", propMap["emailAddress"]) + assert.Equal(t, "1234", propMap["ephemeralId"]) +} + +func Test_KCEntityResolutionNotFoundInferClientId(t *testing.T) { + csqr := map[string]string{ + "clientId=random": "[]", + } + server := testServer(t, nil, nil, nil, nil, csqr) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_ClientId{ClientId: "random"}}) + + kcconfig := testConfigInferID(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + assert.NotNil(t, entityRepresentations) + assert.Len(t, entityRepresentations, 1) + + assert.Equal(t, "1234", entityRepresentations[0].GetOriginalId()) + assert.Len(t, entityRepresentations[0].GetAdditionalProps(), 1) + propMap := entityRepresentations[0].GetAdditionalProps()[0].AsMap() + assert.Equal(t, "random", propMap["clientId"]) + assert.Equal(t, "1234", propMap["ephemeralId"]) +} + +func Test_KCEntityResolutionNotFoundNotInferUsername(t *testing.T) { + server := testServer(t, map[string]string{ + "exact=true&username=randomuser": "[]", + }, nil, nil, nil, nil) + defer server.Close() + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_UserName{UserName: "randomuser"}}) + + kcconfig := testConfigInferID(server) + + req := entityresolutionV2.ResolveEntitiesRequest{} + req.Entities = validBody + + resp, reserr := keycloak.EntityResolution(t.Context(), &req, kcconfig, logger.CreateTestLogger()) + + require.Error(t, reserr) + assert.Equal(t, &entityresolutionV2.ResolveEntitiesResponse{}, &resp) + entityNotFound := entityresolutionV2.EntityNotFoundError{Code: int32(codes.NotFound), Message: keycloak.ErrGetRetrievalFailed.Error(), Entity: "randomuser"} + expectedError := connect.NewError(connect.Code(entityNotFound.GetCode()), keycloak.ErrGetRetrievalFailed) + assert.Equal(t, expectedError, reserr) +} diff --git a/service/entityresolution/v2/entity_resolution.go b/service/entityresolution/v2/entity_resolution.go new file mode 100644 index 0000000000..51eca766c3 --- /dev/null +++ b/service/entityresolution/v2/entity_resolution.go @@ -0,0 +1,53 @@ +package entityresolution + +import ( + "github.com/go-viper/mapstructure/v2" + ersV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" + "github.com/opentdf/platform/protocol/go/entityresolution/v2/entityresolutionv2connect" + claims "github.com/opentdf/platform/service/entityresolution/claims/v2" + keycloak "github.com/opentdf/platform/service/entityresolution/keycloak/v2" + "github.com/opentdf/platform/service/pkg/serviceregistry" + "go.opentelemetry.io/otel/trace" +) + +type ERSConfig struct { + Mode string `mapstructure:"mode" json:"mode"` +} + +const ( + KeycloakMode = "keycloak" + ClaimsMode = "claims" +) + +type EntityResolution struct { + entityresolutionv2connect.EntityResolutionServiceHandler + trace.Tracer +} + +func NewRegistration() *serviceregistry.Service[entityresolutionv2connect.EntityResolutionServiceHandler] { + return &serviceregistry.Service[entityresolutionv2connect.EntityResolutionServiceHandler]{ + ServiceOptions: serviceregistry.ServiceOptions[entityresolutionv2connect.EntityResolutionServiceHandler]{ + Namespace: "entityresolution", + ServiceDesc: &ersV2.EntityResolutionService_ServiceDesc, + ConnectRPCFunc: entityresolutionv2connect.NewEntityResolutionServiceHandler, + RegisterFunc: func(srp serviceregistry.RegistrationParams) (entityresolutionv2connect.EntityResolutionServiceHandler, serviceregistry.HandlerServer) { + var inputConfig ERSConfig + + if err := mapstructure.Decode(srp.Config, &inputConfig); err != nil { + panic(err) + } + if inputConfig.Mode == ClaimsMode { + claimsSVC, claimsHandler := claims.RegisterClaimsERS(srp.Config, srp.Logger) + claimsSVC.Tracer = srp.Tracer + return EntityResolution{EntityResolutionServiceHandler: claimsSVC}, claimsHandler + } + + // Default to keycloak ERS + kcSVC, kcHandler := keycloak.RegisterKeycloakERS(srp.Config, srp.Logger) + kcSVC.Tracer = srp.Tracer + + return EntityResolution{EntityResolutionServiceHandler: kcSVC, Tracer: srp.Tracer}, kcHandler + }, + }, + } +} diff --git a/service/go.mod b/service/go.mod index 3a8c4a7fcd..0e54cc2fe5 100644 --- a/service/go.mod +++ b/service/go.mod @@ -30,7 +30,7 @@ require ( github.com/opentdf/platform/lib/flattening v0.1.3 github.com/opentdf/platform/lib/identifier v0.0.0-20250506204946-8822c4516627 github.com/opentdf/platform/lib/ocrypto v0.1.9 - github.com/opentdf/platform/protocol/go v0.3.2 + github.com/opentdf/platform/protocol/go v0.3.3 github.com/opentdf/platform/sdk v0.4.4 github.com/pressly/goose/v3 v3.19.1 github.com/spf13/cobra v1.9.1 diff --git a/service/go.sum b/service/go.sum index f1e9408281..4c00374105 100644 --- a/service/go.sum +++ b/service/go.sum @@ -272,8 +272,8 @@ github.com/opentdf/platform/lib/identifier v0.0.0-20250506204946-8822c4516627 h1 github.com/opentdf/platform/lib/identifier v0.0.0-20250506204946-8822c4516627/go.mod h1:/tHnLlSVOq3qmbIYSvKrtuZchQfagenv4wG5twl4oRs= github.com/opentdf/platform/lib/ocrypto v0.1.9 h1:GvgPB7CoK7JmWvsSvJ0hc+RC0wezgcuRpy3q2oYKjdA= github.com/opentdf/platform/lib/ocrypto v0.1.9/go.mod h1:UTtqh8mvhAYA+sEnaMxpr/406e84L5Q1sAxtKGIXfu4= -github.com/opentdf/platform/protocol/go v0.3.2 h1:WugeSl7RSRM7e7c5jJumZOIW2jr+sMqwDzpGUGyeC5k= -github.com/opentdf/platform/protocol/go v0.3.2/go.mod h1:nErYkgt32GW22CNqSyLO+JE49C3JndI1TsVdF+CUYd4= +github.com/opentdf/platform/protocol/go v0.3.3 h1:ySnnMniOFpxnc4G2EPGQEuEzaYAfLtNhqqIkGv1XXQM= +github.com/opentdf/platform/protocol/go v0.3.3/go.mod h1:nErYkgt32GW22CNqSyLO+JE49C3JndI1TsVdF+CUYd4= github.com/opentdf/platform/sdk v0.4.4 h1:jBJPXZBOodmanla9aS1aaPQgcg7zqOEbBTLF0c0BULM= github.com/opentdf/platform/sdk v0.4.4/go.mod h1:xPjymAKCbFzo+z+PvFVa10NOT+9i5ljxmJaGJ9tkPrw= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= diff --git a/service/pkg/server/services.go b/service/pkg/server/services.go index 6f9eed1616..917909fe4b 100644 --- a/service/pkg/server/services.go +++ b/service/pkg/server/services.go @@ -12,6 +12,7 @@ import ( "github.com/opentdf/platform/sdk" "github.com/opentdf/platform/service/authorization" "github.com/opentdf/platform/service/entityresolution" + entityresolutionV2 "github.com/opentdf/platform/service/entityresolution/v2" "github.com/opentdf/platform/service/health" "github.com/opentdf/platform/service/internal/server" "github.com/opentdf/platform/service/kas" @@ -72,6 +73,7 @@ func registerCoreServices(reg serviceregistry.Registry, mode []string) ([]string kas.NewRegistration(), wellknown.NewRegistration(), entityresolution.NewRegistration(), + entityresolutionV2.NewRegistration(), }...) services = append(services, policy.NewRegistrations()...) case "core": @@ -88,11 +90,14 @@ func registerCoreServices(reg serviceregistry.Registry, mode []string) ([]string return nil, err //nolint:wrapcheck // We are all friends here } case "entityresolution": - // If the mode is "entityresolution", register only the ERS service + // If the mode is "entityresolution", register only the ERS service (v1 and v2) registeredServices = append(registeredServices, serviceEntityResolution) if err := reg.RegisterService(entityresolution.NewRegistration(), modeERS); err != nil { return nil, err //nolint:wrapcheck // We are all friends here } + if err := reg.RegisterService(entityresolutionV2.NewRegistration(), modeERS); err != nil { + return nil, err //nolint:wrapcheck // We are all friends here + } default: continue } diff --git a/service/pkg/server/services_test.go b/service/pkg/server/services_test.go index bbe74a75b0..a2048f2305 100644 --- a/service/pkg/server/services_test.go +++ b/service/pkg/server/services_test.go @@ -124,7 +124,8 @@ func (suite *ServiceTestSuite) Test_RegisterCoreServices_In_Mode_ALL_Expect_All_ ers, err := registry.GetNamespace(serviceEntityResolution) suite.Require().NoError(err) - suite.Len(ers.Services, 1) + ersServiceVersionsCount := 2 + suite.Len(ers.Services, ersServiceVersionsCount) suite.Equal(modeCore, ers.Mode) } @@ -209,7 +210,8 @@ func (suite *ServiceTestSuite) Test_RegisterServices_In_Mode_Core_Plus_Kas_Expec ers, err := registry.GetNamespace(serviceEntityResolution) suite.Require().NoError(err) - suite.Len(ers.Services, 1) + ersServiceVersionsCount := 2 + suite.Len(ers.Services, ersServiceVersionsCount) suite.Equal(modeERS, ers.Mode) }