Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
7094768
adr
elizabethhealy Mar 30, 2026
51273df
namespaced policy in decisions
elizabethhealy Mar 31, 2026
5d1dfd1
add cukes
elizabethhealy Mar 31, 2026
3441f0d
more cukes scenarios
elizabethhealy Mar 31, 2026
194bf75
Merge branch 'main' into dspx-2753-namespaced-policy-decisioning
elizabethhealy Mar 31, 2026
3ca7554
lint, attempt to fix bdd
elizabethhealy Mar 31, 2026
26740fd
use require
elizabethhealy Mar 31, 2026
6278ba4
code rabbit suggestions
elizabethhealy Mar 31, 2026
d26824c
remove file
elizabethhealy Mar 31, 2026
3af77a2
direct entitlement handling
elizabethhealy Mar 31, 2026
fc917bf
linting, rename function
elizabethhealy Apr 1, 2026
d2fed66
update ok
elizabethhealy Apr 1, 2026
2d17070
update features, change attr naming
elizabethhealy Apr 1, 2026
e03b0e5
add comments, fix attr rule
elizabethhealy Apr 1, 2026
7ef6a6e
coderabbit suggestions
elizabethhealy Apr 1, 2026
6ee91c4
registered resourced namespaced get decisions cukes
elizabethhealy Apr 1, 2026
6a859db
linting, fix feature file aav
elizabethhealy Apr 1, 2026
eb7dd89
handle namespaced rr fqn indexing
elizabethhealy Apr 2, 2026
23a877a
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 2, 2026
1fa8804
update go mod
elizabethhealy Apr 2, 2026
15b110f
trim space
elizabethhealy Apr 2, 2026
4743bc1
rename flag, address comment
elizabethhealy Apr 7, 2026
ca09e51
suggestions
elizabethhealy Apr 7, 2026
a9915b3
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 7, 2026
49eec3f
lint
elizabethhealy Apr 8, 2026
287c07b
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 8, 2026
3f4723e
Merge branch 'main' into dspx-2753-rr-aav-bdd-namespaced-policy-tests
elizabethhealy Apr 8, 2026
fb9554b
Merge branch 'main' into dspx-2753-namespaced-policy-decisioning
elizabethhealy Apr 8, 2026
63342df
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 8, 2026
5e8b6bc
remove id/name mismatch logs
elizabethhealy Apr 9, 2026
5078bd2
remove trace log for action id/name mismatch
elizabethhealy Apr 9, 2026
f1bfac9
RR entities and resource should be namespaced if flag on
elizabethhealy Apr 10, 2026
1a48887
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 10, 2026
44af78f
add basic attribute rule tests
elizabethhealy Apr 10, 2026
0f57432
move authz out of obl, add multi-resource tests
elizabethhealy Apr 10, 2026
58abc34
gemini comment, add rr features
elizabethhealy Apr 10, 2026
fbc75db
add more obligations features
elizabethhealy Apr 10, 2026
5889055
add direct entitlement tests
elizabethhealy Apr 10, 2026
5eb0db3
better cukes setup with template add specific deny scenarios
elizabethhealy Apr 10, 2026
ef57189
add un-namespaced sm to feature
elizabethhealy Apr 10, 2026
8688af4
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 10, 2026
81b6662
Merge branch 'dspx-2753-rr-aav-bdd-namespaced-policy-tests' into dspx…
elizabethhealy Apr 10, 2026
cf10241
lint
elizabethhealy Apr 10, 2026
57b785c
populate namespace for action for decisioning check
elizabethhealy Apr 13, 2026
1f10165
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 13, 2026
9cb258e
Merge branch 'dspx-2753-rr-aav-bdd-namespaced-policy-tests' into dspx…
elizabethhealy Apr 13, 2026
1384ee5
move into validators, use fqns instead of id, remove scoped
elizabethhealy Apr 15, 2026
5510d3c
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 15, 2026
7373b32
Merge branch 'dspx-2753-rr-aav-bdd-namespaced-policy-tests' into dspx…
elizabethhealy Apr 15, 2026
79e3a29
Merge branch 'main' into dspx-2753-namespaced-policy-decisioning
elizabethhealy Apr 15, 2026
f026dbe
extend integration test
elizabethhealy Apr 15, 2026
7612d4e
linting
elizabethhealy Apr 15, 2026
c89d3e4
lint
elizabethhealy Apr 15, 2026
6eb73e0
lint
elizabethhealy Apr 15, 2026
a6fe877
Merge branch 'dspx-2753-namespaced-policy-decisioning' into dspx-2753…
elizabethhealy Apr 15, 2026
dcd6aea
Merge branch 'dspx-2753-rr-aav-bdd-namespaced-policy-tests' into dspx…
elizabethhealy Apr 15, 2026
4adac6f
Merge branch 'main' into dspx-1311-add-more-authorization-bdd-tests
elizabethhealy Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 139 additions & 7 deletions service/entityresolution/claims/v2/entity_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package claims

import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"strconv"
"strings"

"connectrpc.com/connect"
"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"
Expand All @@ -22,17 +26,30 @@ import (

type EntityResolutionServiceV2 struct {
entityresolutionV2.UnimplementedEntityResolutionServiceServer
logger *logger.Logger
logger *logger.Logger
allowDirectEntitlements bool
trace.Tracer
}

func RegisterClaimsERS(_ config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) {
claimsSVC := EntityResolutionServiceV2{logger: logger}
type Config struct {
AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"`
}

func RegisterClaimsERS(cfg config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) {
var inputConfig Config
if err := mapstructure.Decode(cfg, &inputConfig); err != nil {
logger.Error("failed to decode claims entity resolution configuration", slog.Any("error", err))
log.Fatalf("Failed to decode claims entity resolution configuration: %v", err)
}
claimsSVC := EntityResolutionServiceV2{
logger: logger,
allowDirectEntitlements: inputConfig.AllowDirectEntitlements,
}
return claimsSVC, nil
}
Comment on lines +38 to 49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other ERS constructors handle Config decode errors.
rg -nP --type=go -C5 'func Register(Keycloak|MultiStrategy|Claims)ERS' service/entityresolution/

# Look for existing log.Fatalf usage in service registry paths to gauge convention.
rg -nP --type=go -C2 'log\.Fatalf' service/

Repository: opentdf/platform

Length of output: 12178


Replace log.Fatalf with panic for consistent config decode error handling.

The current log.Fatalf call bypasses deferred cleanup in the server boot path and double-logs (slog Error + stdlib log.Fatalf). Registry-aware ERS constructors like RegisterMultiStrategyERSV2 use panic(fmt.Sprintf(...)) instead, which allows the service registry to catch and handle startup errors gracefully. Align this with that pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/entityresolution/claims/v2/entity_resolution.go` around lines 38 -
49, Replace the stdlib log.Fatalf call in RegisterClaimsERS with a panic using
fmt.Sprintf so startup errors are raised consistently with other registry-aware
ERS constructors; when mapstructure.Decode returns an error, keep the existing
logger.Error line and then call panic(fmt.Sprintf("Failed to decode claims
entity resolution configuration: %v", err)) so the service registry can catch
and handle the panic (refer to RegisterClaimsERS, mapstructure.Decode and
logger.Error in this function).


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)
resp, err := EntityResolution(ctx, req.Msg, s.logger, s.allowDirectEntitlements)
return connect.NewResponse(&resp), err
}

Expand Down Expand Up @@ -63,13 +80,14 @@ func CreateEntityChainsFromTokens(
}

func EntityResolution(_ context.Context,
req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger,
req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, allowDirectEntitlements bool,
) (entityresolutionV2.ResolveEntitiesResponse, error) {
payload := req.GetEntities()
var resolvedEntities []*entityresolutionV2.EntityRepresentation

for idx, ident := range payload {
entityStruct := &structpb.Struct{}
var directEntitlements []*entityresolutionV2.DirectEntitlement
switch ident.GetEntityType().(type) {
case *entity.Entity_Claims:
claims := ident.GetClaims()
Expand All @@ -79,6 +97,13 @@ func EntityResolution(_ context.Context,
return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error unpacking anypb.Any to structpb.Struct: %w", err))
}
}
if allowDirectEntitlements {
var err error
directEntitlements, err = parseDirectEntitlementsFromClaims(entityStruct)
if err != nil {
return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, err)
}
}
default:
retrievedStruct, err := entityToStructPb(ident)
if err != nil {
Expand All @@ -95,8 +120,9 @@ func EntityResolution(_ context.Context,
resolvedEntities = append(
resolvedEntities,
&entityresolutionV2.EntityRepresentation{
OriginalId: originialID,
AdditionalProps: []*structpb.Struct{entityStruct},
OriginalId: originialID,
AdditionalProps: []*structpb.Struct{entityStruct},
DirectEntitlements: directEntitlements,
},
)
}
Expand Down Expand Up @@ -164,3 +190,109 @@ func entityToStructPb(ident *entity.Entity) (*structpb.Struct, error) {
}
return &entityStruct, nil
}

func parseDirectEntitlementsFromClaims(entityStruct *structpb.Struct) ([]*entityresolutionV2.DirectEntitlement, error) {
if entityStruct == nil {
return nil, nil
}
claims := entityStruct.AsMap()
rawEntitlements, ok := claims["direct_entitlements"]
if !ok {
rawEntitlements, ok = claims["directEntitlements"]
}
if !ok {
return nil, nil
}

entitlementList, entitlementsOK := rawEntitlements.([]interface{})
if !entitlementsOK {
return nil, errors.New("direct_entitlements must be an array")
}

out := make([]*entityresolutionV2.DirectEntitlement, 0, len(entitlementList))
for idx, entry := range entitlementList {
entryMap, entryOK := entry.(map[string]interface{})
if !entryOK {
return nil, fmt.Errorf("direct_entitlements[%d] must be an object", idx)
}

fqn, err := parseDirectEntitlementFQN(entryMap)
if err != nil {
return nil, fmt.Errorf("direct_entitlements[%d] %w", idx, err)
}

rawActions, actionsOK := entryMap["actions"]
if !actionsOK {
return nil, fmt.Errorf("direct_entitlements[%d] missing actions", idx)
}
actions, err := parseDirectEntitlementActions(rawActions)
if err != nil {
return nil, fmt.Errorf("direct_entitlements[%d] invalid actions: %w", idx, err)
}

out = append(out, &entityresolutionV2.DirectEntitlement{
AttributeValueFqn: fqn,
Actions: actions,
})
}

return out, nil
}

func parseDirectEntitlementFQN(entry map[string]interface{}) (string, error) {
if raw, ok := entry["attribute_value_fqn"]; ok {
if fqn, fqnOK := raw.(string); fqnOK {
fqn = strings.TrimSpace(fqn)
if fqn != "" {
return fqn, nil
}
}
}
if raw, ok := entry["attributeValueFqn"]; ok {
if fqn, fqnOK := raw.(string); fqnOK {
fqn = strings.TrimSpace(fqn)
if fqn != "" {
return fqn, nil
}
}
}
return "", errors.New("missing attribute_value_fqn")
}

func parseDirectEntitlementActions(raw interface{}) ([]string, error) {
actions := make([]string, 0)
switch typed := raw.(type) {
case []interface{}:
for _, action := range typed {
actionStr, ok := action.(string)
if !ok {
return nil, errors.New("action must be a string")
}
actionStr = strings.TrimSpace(strings.ToLower(actionStr))
if actionStr != "" {
actions = append(actions, actionStr)
}
}
case []string:
for _, action := range typed {
action = strings.TrimSpace(strings.ToLower(action))
if action != "" {
actions = append(actions, action)
}
}
case string:
for _, action := range strings.Split(typed, ",") {
action = strings.TrimSpace(strings.ToLower(action))
if action != "" {
actions = append(actions, action)
}
}
default:
return nil, errors.New("actions must be an array or string")
}

if len(actions) == 0 {
return nil, errors.New("no actions provided")
}
return actions, nil
}
Comment on lines +262 to +298
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor: the []string branch is unreachable via structpb.Struct.AsMap().

structpb.Value_ListValue.AsInterface() produces []interface{} — never []string. The case []string (line 276-282) is defensive dead code from this caller's perspective. Safe to keep for robustness if you envision non-structpb inputs later, but worth a brief comment or removal to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/entityresolution/claims/v2/entity_resolution.go` around lines 262 -
298, In parseDirectEntitlementActions, the case handling []string is effectively
unreachable when inputs come from structpb (Struct.AsMap/Value_ListValue yields
[]interface{}); either remove the []string branch to avoid confusion or keep it
but add a brief comment above the case noting it is defensive for non-structpb
inputs, so future readers understand why it exists; reference the function name
parseDirectEntitlementActions and the []string case to locate the change.

65 changes: 62 additions & 3 deletions service/entityresolution/claims/v2/entity_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func Test_ClientResolveEntity(t *testing.T) {
req := entityresolutionV2.ResolveEntitiesRequest{}
req.Entities = validBody

resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger())
resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false)

require.NoError(t, reserr)

Expand All @@ -44,7 +44,7 @@ func Test_EmailResolveEntity(t *testing.T) {
req := entityresolutionV2.ResolveEntitiesRequest{}
req.Entities = validBody

resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger())
resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false)

require.NoError(t, reserr)

Expand Down Expand Up @@ -78,7 +78,7 @@ func Test_ClaimsResolveEntity(t *testing.T) {
req := entityresolutionV2.ResolveEntitiesRequest{}
req.Entities = validBody

resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger())
resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false)

require.NoError(t, reserr)

Expand All @@ -93,6 +93,65 @@ func Test_ClaimsResolveEntity(t *testing.T) {
assert.EqualValues(t, 42, propMap["baz"])
}

func Test_ClaimsResolveEntityDirectEntitlements(t *testing.T) {
customclaims := map[string]interface{}{
"direct_entitlements": []interface{}{
map[string]interface{}{
"attribute_value_fqn": "https://example.com/attr/department/value/eng",
"actions": []interface{}{"read", "update"},
},
},
}
structClaims, err := structpb.NewStruct(customclaims)
require.NoError(t, err)

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{Entities: validBody}

resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), true)
require.NoError(t, reserr)

entityRepresentations := resp.GetEntityRepresentations()
require.Len(t, entityRepresentations, 1)

entitlements := entityRepresentations[0].GetDirectEntitlements()
require.Len(t, entitlements, 1)
assert.Equal(t, "https://example.com/attr/department/value/eng", entitlements[0].GetAttributeValueFqn())
assert.ElementsMatch(t, []string{"read", "update"}, entitlements[0].GetActions())
}

func Test_ClaimsResolveEntityDirectEntitlementsDisabled(t *testing.T) {
customclaims := map[string]interface{}{
"direct_entitlements": []interface{}{
map[string]interface{}{
"attribute_value_fqn": "https://example.com/attr/department/value/eng",
"actions": []interface{}{"read"},
},
},
}
structClaims, err := structpb.NewStruct(customclaims)
require.NoError(t, err)

anyClaims, err := anypb.New(structClaims)
require.NoError(t, err)

req := entityresolutionV2.ResolveEntitiesRequest{Entities: []*entity.Entity{
{EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}},
}}

resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false)
require.NoError(t, reserr)

entityRepresentations := resp.GetEntityRepresentations()
require.Len(t, entityRepresentations, 1)
assert.Empty(t, entityRepresentations[0].GetDirectEntitlements())
}
Comment on lines +96 to +153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

LGTM — good positive/negative coverage for the new flag.

The two new tests tightly mirror the enabled (true) and disabled (false) configuration paths introduced in EntityResolution, and assert.ElementsMatch on actions correctly avoids coupling to ordering. Paired with the assert.Empty(... GetDirectEntitlements()) on the disabled path, these protect against regressions in either direction (flag off accidentally emitting entitlements, or flag on dropping them).

Optional follow-up (nice-to-have, not blocking): consider a third case that exercises malformed/partial direct_entitlements claim content (e.g., missing attribute_value_fqn, non-array actions) so the parser's error/skip behavior is pinned down by a test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/entityresolution/claims/v2/entity_resolution_test.go` around lines 96
- 153, Add a third test (e.g.,
Test_ClaimsResolveEntityDirectEntitlementsMalformed) in
entity_resolution_test.go that builds a claims struct with malformed/partial
"direct_entitlements" (e.g., an entry missing "attribute_value_fqn" and another
with "actions" as a non-array), call claims.EntityResolution(...) with the
feature flag enabled (true), and assert the function safely ignores malformed
entries (no panic/error and resulting
entityRepresentations[0].GetDirectEntitlements() does not include invalid items
— expect empty or only valid entries) to pin down parser skip/error behavior;
reference claims.EntityResolution and the existing
Test_ClaimsResolveEntityDirectEntitlements/Test_ClaimsResolveEntityDirectEntitlementsDisabled
for structure.


func Test_JWTToEntityChainClaims(t *testing.T) {
validBody := []*entity.Token{{Jwt: samplejwt}}

Expand Down
51 changes: 51 additions & 0 deletions tests-bdd/cukes/resources/platform.direct_entitlements.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
authEndpoint: &authEndpoint http://{{ .hostname }}:{{.kcPort }}/auth
issuerEndpoint: &issuerEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}}
tokenEndpoint: &tokenEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}}/protocol/openid-connect/token
entityResolutionServiceUrl: &entityResolutionServiceUrl https://{{ .hostname }}:{{.platformPort }}/entityresolution/resolve
platformEndpoint: &platformEndpoint https://{{.hostname }}:{{.platformPort }}
authRealm: &authRealm {{.authRealm}}
mode: all
logger:
level: debug
type: text
output: stdout
server:
port: {{.platformPort}}
auth:
enabled: true
enforceDPoP: false
audience: *platformEndpoint
issuer: *issuerEndpoint
policy:
extension: |
g, opentdf-admin, role:admin
g, opentdf-standard, role:standard
db:
host: {{ .pgHost }}
port: {{ .pgPort }}
database: {{ .pgDatabase }}
user: postgres
password: changeme
schema: otdf
services:
authorization:
allow_direct_entitlements: true
kas:
keyring:
- kid: e1
alg: ec:secp256r1
- kid: r1
alg: rsa:2048
entityresolution:
mode: claims
allow_direct_entitlements: true
shared:
clientId: otdf-shared
clientSecret: secret
authClientId: otdf-shared-auth
serviceHostName: shared
platformEndpoint: *platformEndpoint
platformAuthEndpoint: *authEndpoint
platformAuthRealm: *authRealm
tokenEndpoint: *tokenEndpoint
# ...other service configs as needed...
Loading
Loading