Skip to content

Conversation

@ghaskins
Copy link
Contributor

@ghaskins ghaskins commented Jan 15, 2026

IIntroduce a general-purpose authorization backend using HTTP-based Policy
Decision Points (PDPs). This authorizer can work with any PDP server that
implements the PORC (Principal-Operation-Resource-Context) decision endpoint.

Key features:

  • HTTP client for connecting to PDP servers via /decision endpoint
  • PORC mapping for MCP requests (Principal, Operation, Resource, Context)
  • Configurable context inclusion (args, operation metadata)
  • JWT claim extraction for principal attributes (roles, groups, scopes)

The authorizer uses a simple API contract:

  • POST /decision with PORC JSON body
  • Response: {"allow": true/false}

Compatible with Manetu PolicyEngine (MPE) and any custom PDP implementing
the same API.

Large PR Justification

Multiple related changes that would break if separated

@github-actions github-actions bot added the size/XL Extra large PR: 1000+ lines changed label Jan 15, 2026
Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Large PR Detected

This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.

How to unblock this PR:

Add a section to your PR description with the following format:

## Large PR Justification

[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformation

Alternative:

Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.

See our Contributing Guidelines for more details.


This review will be automatically dismissed once you add the justification section.

@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jan 15, 2026
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jan 15, 2026
@codecov
Copy link

codecov bot commented Jan 15, 2026

Codecov Report

❌ Patch coverage is 81.96721% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.73%. Comparing base (a5f957f) to head (b55e1e9).

Files with missing lines Patch % Lines
pkg/authz/authorizers/http/porc.go 80.95% 4 Missing and 8 partials ⚠️
pkg/authz/authorizers/http/core.go 78.72% 6 Missing and 4 partials ⚠️
pkg/authz/authorizers/http/http_client.go 82.14% 5 Missing and 5 partials ⚠️
pkg/authz/authorizers/http/config.go 94.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3315      +/-   ##
==========================================
+ Coverage   64.57%   64.73%   +0.16%     
==========================================
  Files         369      373       +4     
  Lines       35953    36136     +183     
==========================================
+ Hits        23215    23392     +177     
+ Misses      10913    10896      -17     
- Partials     1825     1848      +23     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Collaborator

@JAORMX JAORMX left a comment

Choose a reason for hiding this comment

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

Do you mind if we generalize this a little bit more? I am really keen on this authorizer because it's so general purpose. Having an HTTP-based authorizer is something that's applicable quite generally and could potentially be used with other PDPs that would respect the same API signature.

So... Tell me what you think about this:

  • Let's rename this authorizer to something more general like: httpv1 or something of the sort.
  • We shall keep the PORC mappings and... basically keep this same implementation.
  • Let's remove the manetu MPE policy domain examples from this particular PR to keep it constrained and smaller

There are some particular pieces that are tied to MPE (e.g. probe mode), but that's fine, we can keep those in this PR and generalize later.

What do you think?

Regarding the MPE policy domain samples: I want to find a good place for folks to view an e2e sample of this, and there we could add the MPE policy samples. What do you think?

@ghaskins
Copy link
Contributor Author

@JAORMX I pushed an update with your suggested changes

@github-actions github-actions bot dismissed their stale review January 16, 2026 14:56

Large PR justification has been provided. Thank you!

@github-actions
Copy link
Contributor

✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review.

@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jan 16, 2026
@ghaskins ghaskins changed the title Add Manetu PolicyEngine (MPE) authorizer Add HTTP-based PDP authorizer Jan 17, 2026
Introduce a general-purpose authorization backend using HTTP-based Policy
Decision Points (PDPs). This authorizer can work with any PDP server that
implements the PORC (Principal-Operation-Resource-Context) decision endpoint.

Key features:
- HTTP client for connecting to PDP servers via /decision endpoint
- PORC mapping for MCP requests (Principal, Operation, Resource, Context)
- Configurable context inclusion (args, operation metadata)
- JWT claim extraction for principal attributes (roles, groups, scopes)

The authorizer uses a simple API contract:
- POST /decision with PORC JSON body
- Response: {"allow": true/false}

Compatible with Manetu PolicyEngine (MPE) and any custom PDP implementing
the same API.

Signed-off-by: Greg Haskins <[email protected]>
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jan 17, 2026
| MCP Concept | PORC Field | Format |
|-------------|------------|--------|
| Client identity | `principal.sub` | From JWT `sub` claim |
| Roles | `principal.mroles` | From JWT `roles` or `mroles` claim |
Copy link
Contributor

Choose a reason for hiding this comment

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

why mroles or mgroups instead of roles/groups?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

MPE is derived from our platform product, and our platform product had a scheme where any Manetu-specific claims added to the JWT had an 'm' prefix (mroles, mclearance, mannotations, etc). It probably made more sense when this was an MPE-specific patch, but now that it's moving in a "it's generic HTTP" direction, it stands out more. Not sure what the best solution is, with the current MPE they need those prefixes to work.

Copy link
Contributor

Choose a reason for hiding this comment

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

i believe that this needs to be more abstract, and we could add some mappers between the specific implementations and the generic settings

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's what I am thinking: originally, this PR was written to add "mpev1" to the config. This was generalized to 'httpv1' in recognition that much of it is applicable to many contexts. However, maybe I took it too far in the generalization.

So, what I am thinking is:

  • most of the current logic for http-based authorizers remains as a reusable substrate
  • we have an 'mpev1' type that uses that substrate but does the MPE specific mapping and can have a natural organization to the documentation (e.g. m prefixes needed, operation mandatory, etc).
  • other systems may come along and also share the http-based authorizers code, but put whatever mappings, etc, they need in place in a similar way.

thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

Something like that could works:

We could use a ClaimMapper interface with type-specific implementations:

  • MPEClaimMapper for type: mpev1 - handles m-prefixed claims (mroles, mgroups, mannotations)
  • OIDCClaimMapper for future type: oidcv1 - uses standard OIDC claims (roles, groups)

The mapper would be injected into PORCBuilder based on the authorizer type. This keeps the shared HTTP
infrastructure (client, PORC builder, context config) generic while allowing each PDP type to have its
own claim mapping conventions.

| Groups | `principal.mgroups` | From JWT `groups` or `mgroups` claim |
| Scopes | `principal.scopes` | From JWT `scope` or `scopes` claim |
| MCP operation | `operation` | `mcp:<feature>:<operation>` (e.g., `mcp:tool:call`) |
| MCP resource | `resource` | `mrn:mcp:<server>:<feature>:<id>` (e.g., `mrn:mcp:myserver:tool:weather`) |
Copy link
Contributor

Choose a reason for hiding this comment

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

can you add some comment explaining this format, some link to documentation, etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do (we have an explainer here: https://manetu.github.io/policyengine/concepts/mrn). Ill add this to the docs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I should add: As you will see in the note here: https://manetu.github.io/policyengine/concepts/mrn#mrn-format

The MRN format is optional. All we really care about is unique strings. We do encourage good organization, and the MRN scheme is an example of how to accomplish that. Perhaps what I can do is come up with a generic way to talk about the organizational properties without using "mrn" in the cited examples

// Default is false.
IncludeArgs bool `json:"include_args,omitempty" yaml:"include_args,omitempty"`

// IncludeOperation enables inclusion of MCP operation metadata in context.mcp:
Copy link
Contributor

Choose a reason for hiding this comment

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

what will happen if we add some context.mcp, but we do not include operation? will it break? is that covered?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

operation is a "mandatory phase", so if its missing, the access control decision will be a DENY and the audit-trail will reflect that at least part of the reason for the DENY was the missing operation. There's some more info available here:

https://manetu.github.io/policyengine/concepts/policy-conjunction#phase-requirements

Copy link
Contributor

Choose a reason for hiding this comment

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

can this be documented as well?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a general-purpose HTTP-based Policy Decision Point (PDP) authorizer that enables ToolHive to delegate authorization decisions to external PDP servers using the PORC (Principal-Operation-Resource-Context) model. The implementation supports configurable context inclusion and JWT claim mapping.

Changes:

  • Implements HTTP PDP authorizer with PORC-based decision API
  • Adds configurable context options for controlling what MCP information is included in authorization requests
  • Provides comprehensive test coverage for all major components (config, HTTP client, PORC builder, core authorizer)

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
pkg/authz/authorizers/http/config.go Configuration types and validation for HTTP PDP connection and context options
pkg/authz/authorizers/http/config_test.go Tests for configuration parsing and validation
pkg/authz/authorizers/http/core.go Core authorizer implementation with factory registration
pkg/authz/authorizers/http/core_test.go Integration tests for the authorizer with mock PDP server
pkg/authz/authorizers/http/http_client.go HTTP client for communicating with PDP decision endpoint
pkg/authz/authorizers/http/http_client_test.go HTTP client tests including error cases and validation
pkg/authz/authorizers/http/porc.go PORC builder for mapping MCP requests to PDP format
pkg/authz/authorizers/http/porc_test.go Comprehensive tests for PORC building logic
pkg/authz/authorizers.go Registration import for the HTTP authorizer
examples/authz-httpv1-config.yaml Example configuration file for HTTP PDP setup
docs/authz.md Documentation for HTTP PDP authorizer with API contract and examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

version: "1.0"
type: httpv1
pdp:
mode: http
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The example configuration shows mode: http field at line 13, but this field does not appear in the ConfigOptions struct definition in config.go. Either the field should be added to the struct and its purpose documented, or it should be removed from the example if it's not used.

Suggested change
mode: http

Copilot uses AI. Check for mistakes.
```

The configuration fields are:

Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Documentation inconsistency: The configuration documentation at lines 413-416 mentions three fields, but the example at line 13 shows an undocumented mode: http field in the pdp section. This field should either be documented here or removed from the examples.

Suggested change
- `pdp.http.mode`: The mode/transport used to communicate with the PDP (for example, `http`; default: `http`)

Copilot uses AI. Check for mistakes.
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("URL scheme must be http or https")
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The error message could be more helpful by indicating what valid schemes are accepted. Consider changing to: "URL scheme must be http or https, got: " + parsedURL.Scheme. This provides immediate feedback about what was received and what's expected.

Suggested change
return nil, fmt.Errorf("URL scheme must be http or https")
return nil, fmt.Errorf("URL scheme must be http or https, got: %s", parsedURL.Scheme)

Copilot uses AI. Check for mistakes.

// Add probe parameter if specified (for PDPs that support debugging mode)
if probe {
decisionURL += "?probe=true"
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The probe parameter is appended as a query string using simple string concatenation. While this works, it would be more robust to use url.Values and properly encode the query parameters. This ensures proper URL encoding if additional query parameters are added in the future. Consider using: u, _ := url.Parse(decisionURL); q := u.Query(); q.Set("probe", "true"); u.RawQuery = q.Encode(); decisionURL = u.String()

Suggested change
decisionURL += "?probe=true"
u, err := url.Parse(decisionURL)
if err != nil {
return false, fmt.Errorf("failed to parse decision URL: %w", err)
}
q := u.Query()
q.Set("probe", "true")
u.RawQuery = q.Encode()
decisionURL = u.String()

Copilot uses AI. Check for mistakes.
Comment on lines +174 to +208
func (b *PORCBuilder) buildContext(
feature authorizers.MCPFeature,
operation authorizers.MCPOperation,
resourceID string,
arguments map[string]interface{},
) map[string]interface{} {
ctx := make(map[string]interface{})

// Only build the mcp object if at least one context option is enabled
if !b.contextConfig.IncludeArgs && !b.contextConfig.IncludeOperation {
return ctx
}

// Build nested MCP object with metadata based on configuration
mcp := make(map[string]interface{})

// Include operation metadata if enabled
if b.contextConfig.IncludeOperation {
mcp["feature"] = string(feature)
mcp["operation"] = string(operation)
mcp["resource_id"] = resourceID
}

// Include tool/prompt arguments if enabled and present
if b.contextConfig.IncludeArgs && len(arguments) > 0 {
mcp["args"] = arguments
}

// Only add mcp to context if it has any fields
if len(mcp) > 0 {
ctx["mcp"] = mcp
}

return ctx
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Potential inconsistency: The function returns an empty context map when no options are enabled (line 184), but then checks if the mcp object has any fields before adding it (line 203). This creates a scenario where if only IncludeArgs is true but arguments is empty/nil, the function returns an empty context without an mcp object. This behavior should be explicitly documented in the function comment to clarify that the mcp object is only added when it would have at least one field, preventing the inclusion of empty mcp objects in the PORC.

Copilot uses AI. Check for mistakes.

- `pdp.http.url`: The base URL of the PDP server (required)
- `pdp.http.timeout`: HTTP request timeout in seconds (default: 30)
- `pdp.http.insecure_skip_verify`: Skip TLS certificate verification (default: false)
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The nolint directive for gosec correctly identifies that InsecureSkipVerify is a security concern. However, the configuration documentation should include a stronger warning that this option should never be used in production environments. Consider adding a security warning in the documentation similar to "WARNING: Only use insecure_skip_verify for local testing. Never enable this in production as it disables TLS certificate validation and makes the connection vulnerable to man-in-the-middle attacks."

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +89
// Note: Returns map[string]interface{} (not Principal type alias) to ensure
// the PDP can properly unmarshal the PORC structure for identity phase evaluation.
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Inconsistent return type documentation: The comment states "Returns map[string]interface{} (not Principal type alias)" but the function signature shows the return type as map[string]interface{} which is correct. However, the comment should clarify why the type alias cannot be used here. The explanation about PDP unmarshaling is helpful but could be more specific about what technical issue occurs when using the type alias.

Suggested change
// Note: Returns map[string]interface{} (not Principal type alias) to ensure
// the PDP can properly unmarshal the PORC structure for identity phase evaluation.
// Note: This function must return the concrete type map[string]interface{} (and not
// the Principal type alias) because some HTTP-based PDP implementations use
// reflection with type assertions like value.(map[string]interface{}) when
// unmarshaling and evaluating the PORC structure. Those assertions fail for
// named alias types, even if their underlying type is map[string]interface{},
// which would break identity-phase evaluation.

Copilot uses AI. Check for mistakes.
PORC:

| MCP Concept | PORC Field | Format |
|-------------|------------|--------|
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Inconsistent formatting: The table has inconsistent column widths compared to the table at lines 33-36. While this may render correctly, maintaining consistent formatting improves readability of the markdown source. Consider aligning the dashes to match the column widths like in the earlier table.

Suggested change
|-------------|------------|--------|
|-----------|----------|--------|

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +29
serverId string
contextConfig ContextConfig
}

// NewPORCBuilder creates a new PORC builder with the given server ID and context configuration.
func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverId: serverId,
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Inconsistent naming convention: The variable name uses camelCase serverId instead of the Go idiomatic convention of serverID for ID abbreviations. Go style guidelines recommend capitalizing all letters in abbreviations (ID, HTTP, URL, etc.). This should be renamed to serverID throughout the file for consistency with Go conventions.

Suggested change
serverId string
contextConfig ContextConfig
}
// NewPORCBuilder creates a new PORC builder with the given server ID and context configuration.
func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverId: serverId,
serverID string
contextConfig ContextConfig
}
// NewPORCBuilder creates a new PORC builder with the given server ID and context configuration.
func NewPORCBuilder(serverID string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverID: serverID,

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +29
func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverId: serverId,
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

Inconsistent naming convention: The parameter uses camelCase serverId instead of the Go idiomatic convention of serverID for ID abbreviations. Go style guidelines recommend capitalizing all letters in abbreviations (ID, HTTP, URL, etc.).

Suggested change
func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverId: serverId,
func NewPORCBuilder(serverID string, contextConfig ContextConfig) *PORCBuilder {
return &PORCBuilder{
serverId: serverID,

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants