Skip to content

Commit 9861821

Browse files
authored
Add action permissions and policies MCP tools for Port dynamic permissions (#45)
1 parent b476ee1 commit 9861821

File tree

15 files changed

+544
-3
lines changed

15 files changed

+544
-3
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
=======
88

9+
10+
## [0.2.18] - 2025-06-18
11+
12+
### Added
13+
- Added `get_action_permissions` tool to fetch action RBAC and permissions configuration
14+
- Added `update_action_policies` tool to update action policies and dynamic permissions configuration
15+
- Added support for Port's dynamic permissions and policies through new MCP tools
16+
- Added `required_approval` field to `Action` model
17+
918
## [0.2.17] - 2025-06-17
1019

1120
### Added

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ The [Port IO](https://www.getport.io/) MCP server is a [Model Context Protocol (
2222
- **Define rules** - "Add a rule that requires services to have a team owner to reach the Silver level"
2323
- **Setup quality gates** - "Create a rule that checks if services have proper documentation"
2424

25+
### Manage Permissions & RBAC
26+
27+
- **Fetch action permissions** - "What are the current permission settings for this action?"
28+
- **Update action policies** - "Configure approval workflows for the deployment action"
29+
- **Configure dynamic permissions** - "Set up team-based access control for this action"
30+
2531
We're continuously expanding Port MCP's capabilities. Have a suggestion? We'd love to hear your feedback on our [roadmap](https://roadmap.getport.io/ideas)!
2632

2733
# Installation

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-server-port"
3-
version = "0.2.17"
3+
version = "0.2.18"
44
authors = [
55
{ name = "Matan Grady", email = "[email protected]" }
66
]

src/client/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from src.client.agent import PortAgentClient
1010
from src.client.blueprints import PortBlueprintClient
1111
from src.client.entities import PortEntityClient
12+
from src.client.permissions import PortPermissionsClient
1213
from src.client.scorecards import PortScorecardClient
1314
from src.config import config
1415
from src.models.action_run.action_run import ActionRun
@@ -22,8 +23,6 @@
2223
from src.utils.user_agent import get_user_agent
2324

2425
T = TypeVar("T")
25-
26-
2726
class PortClient:
2827
"""Client for interacting with the Port API."""
2928

@@ -56,6 +55,7 @@ def __init__(
5655
self.scorecards = PortScorecardClient(self._client)
5756
self.actions = PortActionClient(self._client)
5857
self.action_runs = PortActionRunClient(self._client)
58+
self.permissions = PortPermissionsClient(self._client)
5959

6060
def _setup_custom_headers(self):
6161
"""Setup custom headers for all HTTP requests."""
@@ -206,3 +206,9 @@ async def create_entity_action_run(self, action_identifier: str, **kwargs) -> Ac
206206

207207
async def get_action_run(self, run_id: str) -> ActionRun:
208208
return await self.wrap_request(lambda: self.action_runs.get_action_run(run_id))
209+
210+
async def get_action_permissions(self, action_identifier: str) -> dict[str, Any]:
211+
return await self.wrap_request(lambda: self.permissions.get_action_permissions(action_identifier))
212+
213+
async def update_action_policies(self, action_identifier: str, policies: dict[str, Any]) -> dict[str, Any]:
214+
return await self.wrap_request(lambda: self.permissions.update_action_policies(action_identifier, policies))

src/client/permissions.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Client for Port permissions and RBAC operations."""
2+
3+
from typing import Any
4+
5+
from pyport import PortClient
6+
7+
from src.utils import logger
8+
9+
10+
class PortPermissionsClient:
11+
"""Client for managing Port permissions and RBAC."""
12+
13+
def __init__(self, client: PortClient):
14+
self._client = client
15+
16+
async def get_action_permissions(self, action_identifier: str) -> dict[str, Any]:
17+
"""Get permissions configuration for a specific action."""
18+
logger.info(f"Getting permissions for action: {action_identifier}")
19+
20+
try:
21+
response = self._client.make_request("GET", f"actions/{action_identifier}/permissions")
22+
result = response.json()
23+
permissions = result.get("permissions", {})
24+
logger.info(f"Permissions: {permissions}")
25+
if result.get("ok"):
26+
# Return the permissions data structure from the permissions endpoint
27+
permissions_info = {
28+
"action_identifier": action_identifier,
29+
"executePermissions": permissions.get("execute", {}),
30+
"approvePermissions": permissions.get("approve", {}),
31+
}
32+
logger.debug(f"Retrieved action permissions: {permissions_info}")
33+
return permissions_info
34+
else:
35+
logger.warning(f"Failed to get action permissions: {result}")
36+
return {}
37+
except Exception as e:
38+
logger.error(f"Error getting action permissions: {e}")
39+
return {}
40+
41+
async def update_action_policies(self, action_identifier: str, policies: dict[str, Any]) -> dict[str, Any]:
42+
"""Update policies configuration for a specific action."""
43+
logger.info(f"Updating policies for action: {action_identifier}")
44+
45+
try:
46+
# Prepare the payload for updating policies - the policies should be sent directly
47+
payload = policies
48+
49+
response = self._client.make_request("PATCH", f"actions/{action_identifier}/permissions", json=payload)
50+
result = response.json()
51+
permissions = result.get("permissions", {})
52+
if result.get("ok"):
53+
updated_info = {
54+
"action_identifier": action_identifier,
55+
"updated_policies": {
56+
"execute": permissions.get("execute", {}),
57+
"approve": permissions.get("approve", {})
58+
},
59+
"success": True,
60+
}
61+
logger.info(f"Successfully updated policies for action: {action_identifier}")
62+
return updated_info
63+
else:
64+
logger.warning(f"Failed to update action policies: {result}")
65+
return {
66+
"action_identifier": action_identifier,
67+
"updated_policies": {},
68+
"success": False,
69+
"error": result.get("message", "Unknown error"),
70+
}
71+
except Exception as e:
72+
logger.error(f"Error updating action policies: {e}")
73+
return {
74+
"action_identifier": action_identifier,
75+
"updated_policies": {},
76+
"success": False,
77+
"error": str(e),
78+
}

src/models/actions/action.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ class ActionCommon(BaseModel):
135135
alias="invocationMethod",
136136
serialization_alias="invocationMethod",
137137
)
138+
required_approval: bool | SkipJsonSchema[None] = Field(
139+
None,
140+
description="Whether approval is required",
141+
alias="requiredApproval",
142+
serialization_alias="requiredApproval",
143+
)
138144
approval_notification: dict[str, Any] | SkipJsonSchema[None] = Field(
139145
None,
140146
description="Approval notification configuration",

src/models/permissions/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Permission models for Port API interactions."""
2+
3+
from .get_action_permissions import GetActionPermissionsToolResponse, GetActionPermissionsToolSchema
4+
from .update_action_policies import UpdateActionPoliciesToolResponse, UpdateActionPoliciesToolSchema
5+
6+
__all__ = [
7+
"GetActionPermissionsToolResponse",
8+
"GetActionPermissionsToolSchema",
9+
"UpdateActionPoliciesToolResponse",
10+
"UpdateActionPoliciesToolSchema",
11+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Get action permissions tool schemas."""
2+
3+
from typing import Any
4+
5+
from pydantic import Field
6+
7+
from src.models.common.base_pydantic import BaseModel
8+
9+
10+
class GetActionPermissionsToolSchema(BaseModel):
11+
"""Schema for get action permissions tool."""
12+
13+
action_identifier: str = Field(description="The identifier of the action to get permissions configuration for")
14+
15+
16+
class GetActionPermissionsToolResponse(BaseModel):
17+
"""Response model for get action permissions tool."""
18+
19+
permissions: dict[str, Any] = Field(description="Permissions configuration for the action")
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Update action policies tool schemas."""
2+
3+
from typing import Any
4+
5+
from pydantic import Field
6+
7+
from src.models.common.base_pydantic import BaseModel
8+
9+
10+
class UpdateActionPoliciesToolSchema(BaseModel):
11+
"""Schema for update action policies tool."""
12+
13+
action_identifier: str = Field(
14+
description="The identifier of the action to update policies for"
15+
)
16+
policies: dict[str, Any] = Field(
17+
description="""Policies configuration to update. This should contain the complete policies structure including:
18+
19+
• **execute**: Execution permissions configuration
20+
- roles: List of roles allowed to execute (e.g., ["Member", "Admin"])
21+
- users: List of specific users allowed to execute
22+
- teams: List of teams allowed to execute
23+
- ownedByTeam: Boolean indicating if team ownership is required
24+
- policy: Dynamic policy with queries and conditions
25+
26+
• **approve**: Approval workflow configuration
27+
- roles: List of roles allowed to approve
28+
- users: List of specific users allowed to approve
29+
- teams: List of teams allowed to approve
30+
- policy: Dynamic policy with queries and conditions for approval
31+
32+
• **policy**: Dynamic conditions using queries and JQ expressions
33+
- queries: Named queries to fetch entities/users
34+
- conditions: JQ expressions that evaluate to true/false
35+
36+
🔑 **CRITICAL IMPLEMENTATION NOTES:**
37+
38+
1. **Team Queries**: Use "$team" meta property, not "team" regular property
39+
- Correct: {"property": "$team", "operator": "containsAny", "value": ["team-name"]}
40+
- Wrong: {"property": "team", "operator": "containsAny", "value": ["team-name"]}
41+
42+
2. **Approval Conditions**: MUST return user identifier arrays, not booleans
43+
- Correct: [.results.experts.entities[].identifier]
44+
- Wrong: [.results.experts.entities[].identifier] | length > 0
45+
46+
3. **User Team Membership Query Pattern**:
47+
```json
48+
{
49+
"rules": [
50+
{"property": "$blueprint", "operator": "=", "value": "_user"},
51+
{"property": "$team", "operator": "containsAny", "value": ["team-name"]}
52+
],
53+
"combinator": "and"
54+
}
55+
```
56+
57+
Example structures based on Port's dynamic permissions:
58+
- Basic team-based: {"execute": {"roles": ["Member"], "teams": ["platform-team"]}}
59+
- Dynamic condition: {"execute": {"roles": ["Member"], "policy": {"queries": {...}, "conditions": [...]}}}
60+
- With approval workflow: {"execute": {...}, "approve": {"roles": ["Admin"], "policy": {...}}}
61+
- Prevent self-approval: Use policy conditions to exclude the executing user from approvers
62+
63+
Supports Port's full dynamic permissions capabilities as described in the Port documentation."""
64+
)
65+
66+
67+
68+
class UpdateActionPoliciesToolResponse(BaseModel):
69+
"""Response model for update action policies tool."""
70+
71+
action_identifier: str = Field(description="The action identifier that was updated")
72+
updated_policies: dict[str, Any] = Field(description="The updated policies configuration")
73+
success: bool = Field(description="Whether the update was successful")

src/tools/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
GetEntityTool,
2727
UpdateEntityTool,
2828
)
29+
from src.tools.permissions import (
30+
GetActionPermissionsTool,
31+
UpdateActionPoliciesTool,
32+
)
2933
from src.tools.scorecard import (
3034
CreateScorecardTool,
3135
DeleteScorecardTool,
@@ -57,4 +61,6 @@
5761
"GetActionTool",
5862
"ListActionsTool",
5963
"TrackActionRunTool",
64+
"GetActionPermissionsTool",
65+
"UpdateActionPoliciesTool",
6066
]

0 commit comments

Comments
 (0)