Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion internal/sts/authorizer/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,35 @@ func (a *Authorizer) authorizeCentral(req *Request) *DenialError {
}

// claimString returns the claim value as a string and whether it was present and a string.
// A dotted name such as "app_metadata.preferences.theme" is resolved by walking nested claim objects.
func claimString(claims map[string]any, name string) (string, bool) {
v, ok := claims[name]
v, ok := lookupClaim(claims, name)
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}

// lookupClaim resolves a claim by name, treating dots as a path into nested objects.
// A literal top-level key takes precedence over path traversal so flat claims are unaffected.
func lookupClaim(claims map[string]any, name string) (any, bool) {
if v, ok := claims[name]; ok {
return v, true
}
var current any = claims
for segment := range strings.SplitSeq(name, ".") {
obj, ok := current.(map[string]any)
if !ok {
return nil, false
}
if current, ok = obj[segment]; !ok {
return nil, false
}
}
return current, true
}

// findProvider returns the provider config for the given issuer, or nil if not found.
func (a *Authorizer) findProvider(issuer string) *config.ProviderConfig {
for i := range a.config.Providers {
Expand Down
79 changes: 79 additions & 0 deletions internal/sts/authorizer/provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2026 Thomson Reuters
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package authorizer

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestClaimString(t *testing.T) {
t.Parallel()

claims := map[string]any{
"repository": "example-org/example-repo",
"app_metadata": map[string]any{
"plan": "premium",
"roles": []any{"admin", "editor"},
"preferences": map[string]any{
"theme": "dark",
"notifications": true,
},
},
}

tests := []struct {
name string
field string
want string
ok bool
}{
{name: "top-level claim", field: "repository", want: "example-org/example-repo", ok: true},
{name: "nested claim", field: "app_metadata.plan", want: "premium", ok: true},
{name: "deeply nested claim", field: "app_metadata.preferences.theme", want: "dark", ok: true},
{name: "missing top-level claim", field: "missing", ok: false},
{name: "missing nested claim", field: "app_metadata.missing", ok: false},
{name: "path descends past an object leaf", field: "repository.nope", ok: false},
{name: "path descends past a scalar leaf", field: "app_metadata.plan.nope", ok: false},
{name: "non-string leaf (bool)", field: "app_metadata.preferences.notifications", ok: false},
{name: "non-string leaf (array)", field: "app_metadata.roles", ok: false},
{name: "object node is not a string", field: "app_metadata", ok: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, ok := claimString(claims, tt.field)
assert.Equal(t, tt.ok, ok)
assert.Equal(t, tt.want, got)
})
}
}

func TestClaimString_LiteralKeyWinsOverPath(t *testing.T) {
t.Parallel()

claims := map[string]any{
"app_metadata.theme": "flat",
"app_metadata": map[string]any{
"theme": "nested",
},
}

got, ok := claimString(claims, "app_metadata.theme")
assert.True(t, ok)
assert.Equal(t, "flat", got)
}
58 changes: 58 additions & 0 deletions test/integration/evaluation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,64 @@ func TestEvaluation_SubjectClaimPatternMatching(t *testing.T) {
})
}

// TestEvaluation_NestedClaimMatching verifies that a claim addressed by a
// dotted path into a nested object is correctly matched against policy conditions.
func TestEvaluation_NestedClaimMatching(t *testing.T) {
tests := []struct {
name string
metadata map[string]any
matches bool
}{
{
name: "nested value matches",
metadata: map[string]any{
"preferences": map[string]any{"theme": "dark"},
},
matches: true,
},
{
name: "nested value does not match",
metadata: map[string]any{
"preferences": map[string]any{"theme": "light"},
},
matches: false,
},
{
name: "missing nested object does not match",
metadata: nil,
matches: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := harness.New(t)

harness.StartServer(t, ctx)

ctx.SetupPolicy(harness.DefaultRepo, "nested_claim.tpl.yaml")

overrides := map[string]any{}
if tt.metadata != nil {
overrides["app_metadata"] = tt.metadata
}

got, err := ctx.Client.Exchange(t.Context(), &harness.ExchangeRequest{
OIDCToken: ctx.TokenWith(overrides),
TargetRepository: harness.DefaultRepo,
})
require.NoError(t, err)

if tt.matches {
require.Nil(t, got.Error)
} else {
require.NotNil(t, got.Error)
assert.Equal(t, string(authorizer.ErrNoRulesMatched), got.Error.Code)
}
})
}
}

// TestEvaluation_ORLogicMultipleConditions verifies that OR logic with
// multiple conditions correctly matches when any condition is satisfied.
func TestEvaluation_ORLogicMultipleConditions(t *testing.T) {
Expand Down
34 changes: 34 additions & 0 deletions test/integration/fixtures/policies/nested_claim.tpl.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2026 Thomson Reuters
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Trust Policy Template: Nested Claim Matching
#
# Tests matching a claim addressed by a dotted path into a nested object.

version: "1.0"

trust_policies:
- name: "default"
issuer: "{{ISSUER_URL}}"
rules:
- name: "premium-plan"
conditions:
- field: "repository"
pattern: "example-org/example-repo"
- field: "app_metadata.preferences.theme"
pattern: "^dark$"
logic: "AND"
permissions:
contents: "write"
metadata: "read"
Loading