-
Notifications
You must be signed in to change notification settings - Fork 0
Add author-permission gating for slash commands #13
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
Draft
Keramikus-97
wants to merge
2
commits into
main
Choose a base branch
from
devin/1780916670-author-permission-gating
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Author-permission gating for slash commands. | ||
|
|
||
| Prevents unauthorised users from triggering the OpenCode agent by | ||
| checking the commenter's repository permission level before processing | ||
| any extracted commands. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from opencode_github.github_client import GitHubClient | ||
| from opencode_github.webhook_handler import WebhookEvent | ||
|
|
||
| # GitHub permission levels in descending order of privilege. | ||
| _PERMISSION_RANK: dict[str, int] = { | ||
| "admin": 4, | ||
| "maintain": 3, | ||
| "write": 2, | ||
| "triage": 1, | ||
| "read": 0, | ||
| "none": -1, | ||
| } | ||
|
|
||
| DEFAULT_MIN_PERMISSION = "write" | ||
|
|
||
|
|
||
| def permission_rank(level: str) -> int: | ||
| """Return a numeric rank for a GitHub permission level. | ||
|
|
||
| Unknown levels are treated as ``"none"`` (rank ``-1``). | ||
| """ | ||
| return _PERMISSION_RANK.get(level.lower(), -1) | ||
|
|
||
|
|
||
| def is_authorized(permission: str, min_level: str = DEFAULT_MIN_PERMISSION) -> bool: | ||
| """Return ``True`` when *permission* meets or exceeds *min_level*.""" | ||
| return permission_rank(permission) >= permission_rank(min_level) | ||
|
|
||
|
|
||
| async def check_event_authorization( | ||
| client: GitHubClient, | ||
| event: WebhookEvent, | ||
| min_level: str = DEFAULT_MIN_PERMISSION, | ||
| post_denial: bool = True, | ||
| ) -> bool: | ||
| """Check whether the sender of *event* is authorized to run commands. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| client: | ||
| Authenticated GitHub API client. | ||
| event: | ||
| The parsed webhook event whose ``sender_login`` will be checked. | ||
| min_level: | ||
| Minimum required permission (default ``"write"``). | ||
| post_denial: | ||
| When ``True`` and the user is *not* authorized, post a polite | ||
| comment explaining that the command was ignored. | ||
|
|
||
| Returns | ||
| ------- | ||
| bool | ||
| ``True`` if the user has sufficient permissions. | ||
| """ | ||
| try: | ||
| permission = await client.get_user_permission( | ||
| event.repo_owner, event.repo_name, event.sender_login | ||
| ) | ||
| except Exception: | ||
| # If we cannot determine permission (API error, network timeout, etc.), | ||
| # deny by default. | ||
| permission = "none" | ||
|
|
||
| authorized = is_authorized(permission, min_level) | ||
|
|
||
| if not authorized and post_denial: | ||
| body = ( | ||
| f"@{event.sender_login} Sorry, you need **{min_level}** permission " | ||
| f"(or higher) on this repository to use slash commands. " | ||
| f"Your current permission level is **{permission}**." | ||
| ) | ||
| try: | ||
| await client.create_issue_comment( | ||
| event.repo_owner, event.repo_name, event.issue_number, body | ||
| ) | ||
| except Exception: | ||
| pass # Best-effort; don't fail the whole flow. | ||
|
|
||
| return authorized |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| """Tests for opencode_github.authorization.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import httpx | ||
| import pytest | ||
| import respx | ||
|
|
||
| from opencode_github.authorization import ( | ||
| check_event_authorization, | ||
| is_authorized, | ||
| permission_rank, | ||
| ) | ||
| from opencode_github.github_client import GitHubClient | ||
| from opencode_github.webhook_handler import EventType, WebhookEvent | ||
|
|
||
| BASE = "https://api.github.com" | ||
|
|
||
|
|
||
| def _make_event(sender: str = "alice") -> WebhookEvent: | ||
| return WebhookEvent( | ||
| event_type=EventType.ISSUE_COMMENT, | ||
| action="created", | ||
| comment_body="/oc help", | ||
| comment_id=42, | ||
| sender_login=sender, | ||
| repo_owner="owner", | ||
| repo_name="repo", | ||
| issue_number=7, | ||
| raw_payload={}, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def mock_router() -> respx.MockRouter: | ||
| with respx.mock(base_url=BASE, assert_all_called=False) as router: | ||
| yield router | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def client() -> GitHubClient: | ||
| return GitHubClient(token="test-token", base_url=BASE, timeout=5) | ||
|
|
||
|
|
||
| class TestPermissionRank: | ||
| def test_known_levels(self) -> None: | ||
| assert permission_rank("admin") == 4 | ||
| assert permission_rank("maintain") == 3 | ||
| assert permission_rank("write") == 2 | ||
| assert permission_rank("triage") == 1 | ||
| assert permission_rank("read") == 0 | ||
| assert permission_rank("none") == -1 | ||
|
|
||
| def test_case_insensitive(self) -> None: | ||
| assert permission_rank("ADMIN") == 4 | ||
| assert permission_rank("Write") == 2 | ||
|
|
||
| def test_unknown_treated_as_none(self) -> None: | ||
| assert permission_rank("unknown") == -1 | ||
| assert permission_rank("") == -1 | ||
|
|
||
|
|
||
| class TestIsAuthorized: | ||
| def test_admin_always_passes(self) -> None: | ||
| assert is_authorized("admin", "write") is True | ||
| assert is_authorized("admin", "admin") is True | ||
|
|
||
| def test_write_meets_write(self) -> None: | ||
| assert is_authorized("write", "write") is True | ||
|
|
||
| def test_read_below_write(self) -> None: | ||
| assert is_authorized("read", "write") is False | ||
|
|
||
| def test_none_below_everything(self) -> None: | ||
| assert is_authorized("none", "read") is False | ||
|
|
||
| def test_triage_below_write(self) -> None: | ||
| assert is_authorized("triage", "write") is False | ||
|
|
||
| def test_maintain_above_write(self) -> None: | ||
| assert is_authorized("maintain", "write") is True | ||
|
|
||
| def test_custom_min_level_read(self) -> None: | ||
| assert is_authorized("read", "read") is True | ||
| assert is_authorized("none", "read") is False | ||
|
|
||
|
|
||
| class TestCheckEventAuthorization: | ||
| async def test_authorized_user( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/alice/permission").mock( | ||
| return_value=httpx.Response(200, json={"permission": "write"}) | ||
| ) | ||
| event = _make_event("alice") | ||
| result = await check_event_authorization(client, event) | ||
| assert result is True | ||
|
|
||
| async def test_admin_authorized( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/bob/permission").mock( | ||
| return_value=httpx.Response(200, json={"permission": "admin"}) | ||
| ) | ||
| event = _make_event("bob") | ||
| result = await check_event_authorization(client, event) | ||
| assert result is True | ||
|
|
||
| async def test_read_denied_posts_comment( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/eve/permission").mock( | ||
| return_value=httpx.Response(200, json={"permission": "read"}) | ||
| ) | ||
| comment_route = mock_router.post("/repos/owner/repo/issues/7/comments").mock( | ||
| return_value=httpx.Response( | ||
| 201, | ||
| json={ | ||
| "id": 99, | ||
| "body": "denied", | ||
| "user": {"login": "bot"}, | ||
| "html_url": "https://github.com/owner/repo/issues/7#issuecomment-99", | ||
| }, | ||
| ) | ||
| ) | ||
| event = _make_event("eve") | ||
| result = await check_event_authorization(client, event) | ||
| assert result is False | ||
| assert comment_route.called | ||
|
|
||
| async def test_denied_no_comment_when_disabled( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/eve/permission").mock( | ||
| return_value=httpx.Response(200, json={"permission": "read"}) | ||
| ) | ||
| event = _make_event("eve") | ||
| result = await check_event_authorization(client, event, post_denial=False) | ||
| assert result is False | ||
|
|
||
| async def test_api_error_defaults_to_denied( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/ghost/permission").mock( | ||
| return_value=httpx.Response(403, json={"message": "Forbidden"}) | ||
| ) | ||
| mock_router.post("/repos/owner/repo/issues/7/comments").mock( | ||
| return_value=httpx.Response( | ||
| 201, | ||
| json={ | ||
| "id": 100, | ||
| "body": "denied", | ||
| "user": {"login": "bot"}, | ||
| "html_url": "https://github.com/owner/repo/issues/7#issuecomment-100", | ||
| }, | ||
| ) | ||
| ) | ||
| event = _make_event("ghost") | ||
| result = await check_event_authorization(client, event) | ||
| assert result is False | ||
|
|
||
| async def test_custom_min_level( | ||
| self, mock_router: respx.MockRouter, client: GitHubClient | ||
| ) -> None: | ||
| mock_router.get("/repos/owner/repo/collaborators/alice/permission").mock( | ||
| return_value=httpx.Response(200, json={"permission": "read"}) | ||
| ) | ||
| event = _make_event("alice") | ||
| result = await check_event_authorization(client, event, min_level="read") | ||
| assert result is True |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🚩 Permission rank map includes levels that may not be returned by the API field being read
The
_PERMISSION_RANKdict atsrc/opencode_github/authorization.py:14-21defines 6 levels including "maintain" and "triage". Theget_user_permissionmethod readsdata["permission"]from the GitHub API response (src/opencode_github/github_client.py:146). The GitHub REST API'spermissionfield on the collaborator permission endpoint historically returns only the 4-level model ("admin", "write", "read", "none"), while the more granular "maintain" and "triage" levels are only available via therole_nameresponse field. If this is still the case, then settingmin_level="maintain"would incorrectly deny maintain-role users (they'd get "write" from the API, rank 2 < rank 3). The docstring forget_user_permissionclaims it can return "maintain" and "triage", which would be inaccurate. This needs verification against the current GitHub API behavior for the2022-11-28API version.Was this helpful? React with 👍 or 👎 to provide feedback.