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)
}