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
40 changes: 33 additions & 7 deletions docs/authz.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ Create a configuration file (JSON or YAML) with the following structure:
"policies": [
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");",
"permit(principal, action == Action::\"get_prompt\", resource == Prompt::\"greeting\");",
"permit(principal, action == Action::\"read_resource\", resource == Resource::\"data\");"
"permit(principal, action == Action::\"read_resource\", resource == Resource::\"data\");",
"permit(principal, action == Action::\"get_skill\", resource == Skill::\"mcp://example/skill\");"
],
"entities_json": "[]"
}
Expand All @@ -135,6 +136,7 @@ cedar:
- 'permit(principal, action == Action::"call_tool", resource == Tool::"weather");'
- 'permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");'
- 'permit(principal, action == Action::"read_resource", resource == Resource::"data");'
- 'permit(principal, action == Action::"get_skill", resource == Skill::"mcp://example/skill");'
entities_json: "[]"
```

Expand Down Expand Up @@ -181,10 +183,11 @@ In the context of MCP servers, the following entities are used:
- `Action::"call_tool"`: Call a tool
- `Action::"get_prompt"`: Get a prompt
- `Action::"read_resource"`: Read a resource
- `Action::"get_skill"`: Get a skill

Note: List operations (`tools/list`, `prompts/list`, `resources/list`) are always
Note: List operations (`tools/list`, `prompts/list`, `resources/list`, `skills/list`) are always
allowed but the response is filtered based on the corresponding call/get/read policies.
Define policies for the specific operations (call_tool, get_prompt, read_resource)
Define policies for the specific operations (call_tool, get_prompt, read_resource, get_skill)
and the list responses will automatically show only the items the user is authorized to access.

- **Resource**: The object being accessed.
Expand All @@ -194,14 +197,35 @@ In the context of MCP servers, the following entities are used:
- `Prompt::"greeting"`: The greeting prompt
- `Resource::"data"`: A short resource name
- `Resource::"file:///etc/passwd"`: An MCP resource URI (exact URI is the Cedar entity ID)
- `Skill::"mcp://example/skill"`: An MCP skill URI (exact URI is the Cedar entity ID)
- `FeatureType::"tool"`: The tool feature type (used for list operations)

For `read_resource`, the Cedar entity ID is the **exact resource URI** (for example
`Resource::"file:///ok"` or `Resource::"mcp://srv/config:admin"`). Do not rewrite
characters such as `/`, `:`, or `?` into underscores; policies must name the URI as
the client and server see it. In Cedar source the ID is a double-quoted string
literal, so almost every URI character is ordinary, but `"` and `\` must be escaped
(for example `Resource::"file://C:\\share\\data"`).
the client and server see it. Skill URI values follow these same Cedar string-literal
escaping rules. In Cedar source the ID is a double-quoted string literal, so almost
every URI character is ordinary, but `"` and `\` must be escaped (for example
`Resource::"file://C:\\share\\data"`).

#### Skills (SEP-2640 direct proxy)

Direct proxies authorize `skills/get` with `Action::"get_skill"` on an exact
`Skill::"<params.uri>"` entity. The URI is passed through verbatim: it is not
canonicalized and no scheme or suffix is validated. A missing, empty, or non-string
`params.uri` is denied before the authorizer or backend is called. Requests with duplicate
immediate `params.uri` members are likewise denied so the proxy and backend cannot
interpret an ambiguous URI differently.

`skills/list` itself has no separate list policy. It is forwarded and each entry is
shown only when its exact string `uri` is permitted by `get_skill`; all other entries,
including their manifests, are removed. Skill permission is independent of
`read_resource` permission.

This support is only for the direct proxy. Skill capability negotiation, including
initialize extension maps, is passed through unchanged; the proxy neither fabricates
nor rewrites capabilities. Directory reads and all other SEP-2640 operations are not
supported by this authorization layer.

#### Example policies

Expand Down Expand Up @@ -243,13 +267,15 @@ permit(

##### List operations

List operations (`tools/list`, `prompts/list`, `resources/list`) do not require explicit policies.
List operations (`tools/list`, `prompts/list`, `resources/list`, `skills/list`) do not require explicit policies.
They are always allowed but the response is automatically filtered based on the user's permissions
for the corresponding operations:

- `tools/list` shows only tools the user can call (based on `call_tool` policies)
- `prompts/list` shows only prompts the user can get (based on `get_prompt` policies)
- `resources/list` shows only resources the user can read (based on `read_resource` policies)
- `skills/list` shows only skill entries the user can get (based on `get_skill` policies); entries
without exactly one non-empty string `uri` fail closed with a generic internal JSON-RPC error.

For example, if you have this policy:
```plain
Expand Down
71 changes: 58 additions & 13 deletions pkg/authz/authorizers/cedar/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,31 @@ func (a *Authorizer) authorizeResourceRead(
return a.IsAuthorized(principal, action, resource, contextMap, entities)
}

// authorizeSkillGet authorizes a skills/get operation using its exact URI.
func (a *Authorizer) authorizeSkillGet(
clientID, skillURI string,
claimsMap map[string]interface{},
attrsMap map[string]interface{},
groups []string,
) (bool, error) {
principal := fmt.Sprintf("Client::%s", clientID)
attributes := mergeContexts(map[string]interface{}{
"name": skillURI,
"uri": skillURI,
"operation": "get",
"feature": "skill",
}, attrsMap)
entities, err := a.entityFactory.CreateEntitiesForRequest(
principal, "Action::get_skill", fmt.Sprintf("Skill::%s", skillURI),
claimsMap, attributes, groups, a.serverName,
)
if err != nil {
return false, fmt.Errorf("failed to create Cedar entities: %w", err)
}
return a.IsAuthorized(principal, "Action::get_skill", fmt.Sprintf("Skill::%s", skillURI),
mergeContexts(claimsMap, attrsMap), entities)
}

// authorizeFeatureList authorizes a list operation for a feature.
// This method is used when a client tries to list available tools, prompts, or resources.
// It checks if the client is authorized to list the specified feature type.
Expand Down Expand Up @@ -1230,22 +1255,42 @@ func (a *Authorizer) AuthorizeWithJWTClaims(
addMultiValuedClaimSets(processedClaims, resolvedClaims, a.multiValuedClaims)
processedArgs := preprocessArguments(arguments)

// Authorize based on the feature and operation
switch {
case feature == authorizers.MCPFeatureTool && operation == authorizers.MCPOperationCall:
return a.authorizeToolCall(ctx, clientID, resourceID, processedClaims, processedArgs, groups)

case feature == authorizers.MCPFeaturePrompt && operation == authorizers.MCPOperationGet:
return a.authorizePromptGet(clientID, resourceID, processedClaims, processedArgs, groups)

case feature == authorizers.MCPFeatureResource && operation == authorizers.MCPOperationRead:
return a.authorizeResourceRead(clientID, resourceID, processedClaims, processedArgs, groups)

case operation == authorizers.MCPOperationList:
// Authorize based on the feature and operation.
switch operation {
case authorizers.MCPOperationCall:
if feature == authorizers.MCPFeatureTool {
return a.authorizeToolCall(ctx, clientID, resourceID, processedClaims, processedArgs, groups)
}
case authorizers.MCPOperationGet:
return a.authorizeGet(clientID, feature, resourceID, processedClaims, processedArgs, groups)
case authorizers.MCPOperationRead:
if feature == authorizers.MCPFeatureResource {
return a.authorizeResourceRead(clientID, resourceID, processedClaims, processedArgs, groups)
}
case authorizers.MCPOperationList:
return a.authorizeFeatureList(clientID, feature, processedClaims, processedArgs, groups)
}
return false, fmt.Errorf("unsupported feature/operation combination: %s/%s", feature, operation)
}

// authorizeGet dispatches get operations to their feature-specific Cedar mappings.
func (a *Authorizer) authorizeGet(
clientID string,
feature authorizers.MCPFeature,
resourceID string,
claimsMap map[string]interface{},
attrsMap map[string]interface{},
groups []string,
) (bool, error) {
switch feature {
case authorizers.MCPFeaturePrompt:
return a.authorizePromptGet(clientID, resourceID, claimsMap, attrsMap, groups)
case authorizers.MCPFeatureSkill:
return a.authorizeSkillGet(clientID, resourceID, claimsMap, attrsMap, groups)
case authorizers.MCPFeatureTool, authorizers.MCPFeatureResource:
fallthrough
default:
return false, fmt.Errorf("unsupported feature/operation combination: %s/%s", feature, operation)
return false, fmt.Errorf("unsupported get feature: %s", feature)
}
}

Expand Down
5 changes: 4 additions & 1 deletion pkg/authz/authorizers/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (
)

// MCPFeature represents an MCP feature type.
// In the MCP protocol, there are three main features:
// In the MCP protocol, ToolHive authorizes tools, prompts, resources, and skills.
// - Tools: Allow models to call functions in external systems
// - Prompts: Provide structured templates for interacting with language models
// - Resources: Share data that provides context to language models
// - Skills: Package reusable MCP capabilities
type MCPFeature string

const (
Expand All @@ -21,6 +22,8 @@ const (
MCPFeaturePrompt MCPFeature = "prompt"
// MCPFeatureResource represents the MCP resource feature.
MCPFeatureResource MCPFeature = "resource"
// MCPFeatureSkill represents the MCP skill feature.
MCPFeatureSkill MCPFeature = "skill"
)

// MCPOperation represents an operation on an MCP feature.
Expand Down
12 changes: 12 additions & 0 deletions pkg/authz/authorizers/http/porc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ func TestBuildPORC(t *testing.T) {
wantOp: "mcp:resource:read",
wantRes: "mrn:mcp:test:resource:file://data.json",
},
{
name: "skill get",
feature: authorizers.MCPFeatureSkill,
operation: authorizers.MCPOperationGet,
resourceID: "mcp://example/skill",
claims: map[string]interface{}{
"sub": "user@example.com",
},
arguments: nil,
wantOp: "mcp:skill:get",
wantRes: "mrn:mcp:test:skill:mcp://example/skill",
},
{
name: "tool list",
feature: authorizers.MCPFeatureTool,
Expand Down
72 changes: 70 additions & 2 deletions pkg/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
package authz

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
Expand Down Expand Up @@ -52,6 +54,10 @@ var MCPMethodToFeatureOperation = map[string]featureOperation{
"resources/subscribe": {Feature: authorizers.MCPFeatureResource, Operation: authorizers.MCPOperationRead},
"resources/unsubscribe": {Feature: authorizers.MCPFeatureResource, Operation: authorizers.MCPOperationRead},

// Skill operations - list responses are filtered by get authorization.
"skills/get": {Feature: authorizers.MCPFeatureSkill, Operation: authorizers.MCPOperationGet},
"skills/list": {Feature: authorizers.MCPFeatureSkill, Operation: authorizers.MCPOperationList},

// Discovery and capability methods - always allowed
"features/list": {Feature: "", Operation: authorizers.MCPOperationList}, // Capability discovery
"roots/list": {Feature: "", Operation: ""}, // Root directory discovery
Expand Down Expand Up @@ -140,6 +146,59 @@ func shouldSkipSubsequentAuthorization(method string) bool {
return false
}

// invalidSkillGet reports whether a skills/get request lacks exactly one valid URI.
func invalidSkillGet(featureOp featureOperation, resourceID string, params json.RawMessage) bool {
if featureOp.Feature != authorizers.MCPFeatureSkill || featureOp.Operation != authorizers.MCPOperationGet {
return false
}
return resourceID == "" || duplicateSkillURI(params)
}

// hasDuplicateURI reports whether an immediate JSON object has more than one uri member.
// It uses a token decoder because unmarshalling into a map would silently retain only
// the final duplicate member.
func hasDuplicateURI(raw json.RawMessage) (bool, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
token, err := dec.Token()
if err != nil {
return false, err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return false, nil
}

seen := false
for dec.More() {
key, err := dec.Token()
if err != nil {
return false, err
}
if key == "uri" {
if seen {
return true, nil
}
seen = true
}
var value json.RawMessage
if err := dec.Decode(&value); err != nil {
return false, err
}
}
if _, err := dec.Token(); err != nil {
return false, err
}
var trailing json.RawMessage
if err := dec.Decode(&trailing); err != io.EOF {
return false, err
}
return false, nil
}

func duplicateSkillURI(raw json.RawMessage) bool {
duplicate, err := hasDuplicateURI(raw)
return err != nil || duplicate
}

// handleUnauthorized handles unauthorized requests. The client always sees the fixed
// "Unauthorized" message -- err (an authorizer failure) can carry policy detail that
// security.md forbids returning to callers, so it is logged server-side instead.
Expand Down Expand Up @@ -179,9 +238,10 @@ func rejectInvalidMCPRequest(w http.ResponseWriter) {
// This middleware extracts the MCP message from the request, determines the feature,
// operation, and resource ID, and authorizes the request using the configured authorizer.
//
// For list operations (tools/list, prompts/list, resources/list), the middleware allows
// For list operations (tools/list, prompts/list, resources/list, skills/list), the middleware allows
// the request to proceed but intercepts the response to filter out items that the user
// is not authorized to access based on the corresponding call/get/read policies.
// is not authorized to access based on the corresponding call/get/read policy. In
// particular, skills/list entries are filtered individually by get_skill authorization.
//
// An in-memory annotation cache is maintained per middleware instance. When a
// tools/list response passes through, tool annotations are captured. When a
Expand Down Expand Up @@ -255,6 +315,14 @@ func Middleware(a authorizers.Authorizer, next http.Handler, passThroughTools ma
return
}

// skills/get identifies its target only by params.uri. An absent, empty,
// or non-string URI must never reach an authorizer, whose policy might
// otherwise accidentally permit an empty identifier.
if invalidSkillGet(featureOp, parsedRequest.ResourceID, parsedRequest.Params) {
handleUnauthorized(w, parsedRequest.ID, nil)
return
}

// Handle list operations differently - allow them through but filter the response
if featureOp.Operation == authorizers.MCPOperationList {

Expand Down
24 changes: 17 additions & 7 deletions pkg/authz/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,31 @@ import (

// stubAuthorizer is a minimal Authorizer for unit tests, avoiding Cedar setup overhead.
type stubAuthorizer struct {
allowed bool
err error
lastToolID string
lastCtx context.Context
allowed bool
err error
lastID string
lastFeature authorizers.MCPFeature
lastOperation authorizers.MCPOperation
lastCtx context.Context
calls int
authorize func(authorizers.MCPFeature, authorizers.MCPOperation, string) (bool, error)
}

func (s *stubAuthorizer) AuthorizeWithJWTClaims(
ctx context.Context,
_ authorizers.MCPFeature,
_ authorizers.MCPOperation,
feature authorizers.MCPFeature,
operation authorizers.MCPOperation,
resourceID string,
_ map[string]interface{},
) (bool, error) {
s.lastToolID = resourceID
s.lastID = resourceID
s.lastFeature = feature
s.lastOperation = operation
s.lastCtx = ctx
s.calls++
if s.authorize != nil {
return s.authorize(feature, operation, resourceID)
}
return s.allowed, s.err
}

Expand Down
Loading
Loading