-
Notifications
You must be signed in to change notification settings - Fork 170
Add HTTP-based PDP authorizer #3315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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 transformationAlternative:
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.
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this 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:
httpv1or 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?
|
@JAORMX I pushed an update with your suggested changes |
Large PR justification has been provided. Thank you!
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
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]>
| | MCP Concept | PORC Field | Format | | ||
| |-------------|------------|--------| | ||
| | Client identity | `principal.sub` | From JWT `sub` claim | | ||
| | Roles | `principal.mroles` | From JWT `roles` or `mroles` claim | |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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`) | |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this 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 |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| mode: http |
| ``` | ||
|
|
||
| The configuration fields are: | ||
|
|
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| - `pdp.http.mode`: The mode/transport used to communicate with the PDP (for example, `http`; default: `http`) |
| } | ||
|
|
||
| if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | ||
| return nil, fmt.Errorf("URL scheme must be http or https") |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| 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) |
|
|
||
| // Add probe parameter if specified (for PDPs that support debugging mode) | ||
| if probe { | ||
| decisionURL += "?probe=true" |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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()
| 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() |
| 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 | ||
| } |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
|
|
||
| - `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) |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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."
| // Note: Returns map[string]interface{} (not Principal type alias) to ensure | ||
| // the PDP can properly unmarshal the PORC structure for identity phase evaluation. |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| // 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. |
| PORC: | ||
|
|
||
| | MCP Concept | PORC Field | Format | | ||
| |-------------|------------|--------| |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| |-------------|------------|--------| | |
| |-----------|----------|--------| |
| 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
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| 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, |
| func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder { | ||
| return &PORCBuilder{ | ||
| serverId: serverId, |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.).
| func NewPORCBuilder(serverId string, contextConfig ContextConfig) *PORCBuilder { | |
| return &PORCBuilder{ | |
| serverId: serverId, | |
| func NewPORCBuilder(serverID string, contextConfig ContextConfig) *PORCBuilder { | |
| return &PORCBuilder{ | |
| serverId: serverID, |
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:
The authorizer uses a simple API contract:
Compatible with Manetu PolicyEngine (MPE) and any custom PDP implementing
the same API.
Large PR Justification
Multiple related changes that would break if separated