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
29 changes: 6 additions & 23 deletions pkg/authz/authorizers/cedar/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -1084,10 +1084,12 @@ func (a *Authorizer) authorizeResourceRead(
// Action is to read a resource
action := "Action::read_resource"

// Resource is the resource being accessed
// Use the URI as the resource ID, but sanitize it for Cedar
sanitizedURI := sanitizeURIForCedar(resourceURI)
resource := fmt.Sprintf("Resource::%s", sanitizedURI)
// Resource is the resource being accessed. Use the exact URI as the
// entity ID: entities are built programmatically via cedar.NewEntityUID,
// which accepts any string, so no character rewriting is needed. A lossy
// mapping would let distinct URIs collide onto one entity ID, and a
// policy grant on one URI would then silently cover every colliding URI.
resource := fmt.Sprintf("Resource::%s", resourceURI)

// Create attributes for the entities
attributes := mergeContexts(map[string]interface{}{
Expand Down Expand Up @@ -1161,25 +1163,6 @@ func parseCedarEntityID(entityID string) (string, string, error) {
return parts[0], parts[1], nil
}

// sanitizeURIForCedar sanitizes a URI for use in Cedar policies.
// Cedar entity IDs have restrictions on characters, so we need to sanitize the URI.
func sanitizeURIForCedar(uri string) string {
// Replace characters that are not allowed in Cedar entity IDs
// This is a simple implementation - you may need to enhance it based on your needs
replacer := strings.NewReplacer(
":", "_",
"/", "_",
"\\", "_",
"?", "_",
"&", "_",
"=", "_",
"#", "_",
" ", "_",
".", "_",
)
return replacer.Replace(uri)
}

// AuthorizeWithJWTClaims demonstrates how to use JWT claims with the Cedar authorization middleware.
// This method:
// 1. Extracts JWT claims from the context
Expand Down
68 changes: 46 additions & 22 deletions pkg/authz/authorizers/cedar/core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
Expand Down Expand Up @@ -3347,38 +3348,61 @@ func TestParseCedarEntityID(t *testing.T) {
}
}

// TestSanitizeURIForCedar tests the sanitizeURIForCedar helper function.
func TestSanitizeURIForCedar(t *testing.T) {
// TestAuthorizeResourceReadEntityIDsCollisionFree verifies that resource URIs
// become Cedar entity IDs verbatim. Distinct URIs that a lossy sanitization
// would map onto the same entity ID must stay distinct, so a policy grant on
// one URI never authorizes a colliding URI.
func TestAuthorizeResourceReadEntityIDsCollisionFree(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want string
name string
grantedURI string
collidingURI string
}{
{name: "empty_string", input: "", want: ""},
{name: "already_clean", input: "simple_resource", want: "simple_resource"},
{name: "colon", input: "a:b", want: "a_b"},
{name: "forward_slash", input: "a/b", want: "a_b"},
{name: "backslash", input: `a\b`, want: "a_b"},
{name: "question_mark", input: "a?b", want: "a_b"},
{name: "ampersand", input: "a&b", want: "a_b"},
{name: "equals", input: "a=b", want: "a_b"},
{name: "hash", input: "a#b", want: "a_b"},
{name: "space", input: "a b", want: "a_b"},
{name: "dot", input: "a.b", want: "a_b"},
{
name: "complex_uri",
input: "https://api.example.com/v1/data?key=val&other=123#fragment",
want: "https___api_example_com_v1_data_key_val_other_123_fragment",
{
name: "file_uri_colon_and_slash_run",
grantedURI: "file:///etc/passwd",
collidingURI: "file://_etc/passwd",
},
{
name: "mcp_uri_colon_vs_slash",
grantedURI: "mcp://srv/config:admin",
collidingURI: "mcp://srv/config/admin",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sanitizeURIForCedar(tt.input)
assert.Equal(t, tt.want, got)

authorizer, err := NewCedarAuthorizer(ConfigOptions{
Policies: []string{
fmt.Sprintf(`permit(principal, action == Action::"read_resource", resource == Resource::"%s");`, tt.grantedURI),
},
EntitiesJSON: `[]`,
}, "")
require.NoError(t, err, "Failed to create Cedar authorizer")

identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
Subject: "test-user",
Claims: jwt.MapClaims{"sub": "user123"},
}}
claimsCtx := auth.WithIdentity(context.Background(), identity)

// The exact URI named in the policy is authorized.
authorized, err := authorizer.AuthorizeWithJWTClaims(
claimsCtx, authorizers.MCPFeatureResource, authorizers.MCPOperationRead, tt.grantedURI, nil)
require.NoError(t, err)
assert.True(t, authorized, "exact URI named in the policy should be authorized")

// The colliding URI, which mapped to the same entity ID under the
// previous lossy sanitization, must not be authorized.
authorized, err = authorizer.AuthorizeWithJWTClaims(
claimsCtx, authorizers.MCPFeatureResource, authorizers.MCPOperationRead, tt.collidingURI, nil)
require.NoError(t, err)
assert.False(t, authorized,
"colliding URI %q must not be authorized by a grant on %q", tt.collidingURI, tt.grantedURI)
})
}
}
Expand Down