Skip to content

Replace FeatureType with MCP for list operations #4770

@jhrozek

Description

@jhrozek

Part of stacklok/stacklok-enterprise-platform#stacklok/stacklok-enterprise-platform#376

Description

Replace the FeatureType synthetic entity with the MCP server entity as the resource for list operations when serverName is configured on the Cedar authorizer. Currently, authorizeFeatureList uses FeatureType::<feature> (e.g., FeatureType::tool) as the resource entity for list operations. This synthetic type is not part of the enterprise Cedar schema and will never match server-scoped policies like resource in MCP::"github". By switching to MCP::<server> when serverName is set, both Shape 1 (unrestricted grant) and Shape 2 (restricted grant with list/item split) Cedar policies correctly cover list operations.

The change is gated on serverName being non-empty, so existing standalone deployments that use FeatureType::<feature> in hand-written policies are completely unaffected.

Context

The Platform Authorization design compiles CRD-based role bindings into Cedar policies that use resource in MCP::"<server>" for server scoping. Cedar's in operator is reflexive -- MCP::"github" in MCP::"github" evaluates to true -- so a list operation whose resource entity is MCP::"github" naturally matches policies scoped to that server. The current FeatureType::tool resource can never satisfy resource in MCP::"github" because FeatureType is not in the resource hierarchy under MCP.

Making FeatureType a child of MCP was evaluated and rejected: FeatureType::tool is a singleton entity shared across all servers, so in a vMCP deployment with a shared authorizer it cannot simultaneously be a child of multiple MCP parents without breaking server isolation.

This change depends on #4764 which stores serverName on the Authorizer struct, making it available to authorizeFeatureList.

Dependencies: #4764 (serverName on Authorizer)
Blocks: #4771 (end-to-end integration test)

Acceptance Criteria

  • authorizeFeatureList uses MCP::<serverName> as the resource entity when a.serverName != ""
  • authorizeFeatureList preserves FeatureType::<feature> as the resource entity when a.serverName == "" (backward compatibility)
  • List authorization with serverName set evaluates correctly against a policy using resource in MCP::"<server>"
  • List authorization without serverName evaluates correctly against existing FeatureType-based policies
  • All existing tests pass with no behavior change
  • Code reviewed and approved

Technical Approach

Recommended Implementation

A single conditional change in authorizeFeatureList in pkg/authz/authorizers/cedar/core.go. The method currently constructs the resource string unconditionally as FeatureType::<feature>. After this change, it checks a.serverName and uses MCP::<server> when set.

The resource variable construction should use the immutable-assignment-with-anonymous-function pattern preferred by the project:

func (a *Authorizer) authorizeFeatureList(
    clientID string,
    feature authorizers.MCPFeature,
    claimsMap map[string]interface{},
    attrsMap map[string]interface{},
) (bool, error) {
    principal := fmt.Sprintf("Client::%s", clientID)
    action := fmt.Sprintf("Action::list_%ss", feature)

    // Use MCP server entity when serverName is configured (enterprise),
    // fall back to FeatureType for backward compatibility (standalone)
    resource := func() string {
        if a.serverName != "" {
            return fmt.Sprintf("MCP::%s", a.serverName)
        }
        return fmt.Sprintf("FeatureType::%s", feature)
    }()

    // ... rest of the method unchanged ...
}

No other files need modification. The rest of the method (attributes map construction, entity creation, context merging, IsAuthorized call) remains unchanged.

Patterns & Frameworks

  • Immutable variable assignment with anonymous function (project Go style convention)
  • Table-driven tests with testify assertions and t.Parallel() per project testing standards
  • Backward-compatible gating on serverName (empty = current behavior) per design invariant from 00-invariants.md

Code Pointers

  • pkg/authz/authorizers/cedar/core.go lines 499-534 -- authorizeFeatureList method; the resource variable on line 515 (fmt.Sprintf("FeatureType::%s", feature)) is the line to change
  • pkg/authz/authorizers/cedar/core.go lines 116-125 -- Authorizer struct; serverName field will be available here after Store serverName on Authorizer and update NewCedarAuthorizer #4764
  • pkg/authz/authorizers/cedar/core_test.go lines 246-305 -- Existing list authorization tests ("User can list tools", "User can list prompts", "User can list resources"); these use FeatureType-based policies and must continue to pass (backward compat verification)
  • pkg/authz/authorizers/cedar/entity.go lines 88-125 -- CreateEntitiesForRequest; creates entities for the resource string, no change needed here

Component Interfaces

No interface changes. The authorizeFeatureList method signature is unchanged; only the internal resource string construction changes:

// Before (always FeatureType)
resource := fmt.Sprintf("FeatureType::%s", feature)

// After (conditional on serverName)
resource := func() string {
    if a.serverName != "" {
        return fmt.Sprintf("MCP::%s", a.serverName)
    }
    return fmt.Sprintf("FeatureType::%s", feature)
}()

Testing Strategy

Unit Tests

  • authorizeFeatureList with serverName set to "github" and a policy permit(principal, action == Action::"list_tools", resource in MCP::"github") returns authorized (verifies MCP resource is used)
  • authorizeFeatureList with serverName set to "github" and a policy permit(principal, action == Action::"list_tools", resource == FeatureType::"tool") returns denied (verifies FeatureType is NOT used when serverName is set)
  • authorizeFeatureList with empty serverName and a policy permit(principal, action == Action::"list_tools", resource == FeatureType::"tool") returns authorized (backward compat)
  • authorizeFeatureList with empty serverName and a policy permit(principal, action == Action::"list_prompts", resource == FeatureType::"prompt") returns authorized (backward compat, different feature type)
  • authorizeFeatureList with empty serverName and a policy permit(principal, action == Action::"list_resources", resource == FeatureType::"resource") returns authorized (backward compat, different feature type)
  • authorizeFeatureList with serverName set and all three feature types (tool, prompt, resource) each use MCP::<server> as the resource

Integration Tests

  • End-to-end via AuthorizeWithJWTClaims with MCPOperationList and serverName set verifies the complete path from JWT context to MCP-based list authorization

Edge Cases

  • serverName with special characters (e.g., "my-server.example.com") produces a valid Cedar entity ID and authorization succeeds
  • Existing tests in core_test.go for list operations ("User can list tools", "User can list prompts", "User can list resources") continue to pass unchanged (they use authorizers created without serverName)

Out of Scope

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions