Merge pull request #540 from mickey-mikey/feat/tasks-manage-permission
feat: add tasks:manage permission level
This commit is contained in:
@@ -568,6 +568,7 @@ uv run main.py --permissions gmail:send drive:full --tool-tier core
|
|||||||
Granular permissions mode provides service-by-service scope control:
|
Granular permissions mode provides service-by-service scope control:
|
||||||
- Format: `service:level` (one entry per service)
|
- Format: `service:level` (one entry per service)
|
||||||
- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative)
|
- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative)
|
||||||
|
- Tasks levels: `readonly`, `manage`, `full` (cumulative; `manage` allows create/update/move but denies `delete` and `clear_completed`)
|
||||||
- Other services currently support: `readonly`, `full`
|
- Other services currently support: `readonly`, `full`
|
||||||
- `--permissions` and `--read-only` are mutually exclusive
|
- `--permissions` and `--read-only` are mutually exclusive
|
||||||
- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`)
|
- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`)
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ Usage:
|
|||||||
--permissions gmail:organize drive:readonly
|
--permissions gmail:organize drive:readonly
|
||||||
|
|
||||||
Gmail levels: readonly, organize, drafts, send, full
|
Gmail levels: readonly, organize, drafts, send, full
|
||||||
|
Tasks levels: readonly, manage, full
|
||||||
Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS)
|
Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, FrozenSet, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from auth.scopes import (
|
from auth.scopes import (
|
||||||
GMAIL_READONLY_SCOPE,
|
GMAIL_READONLY_SCOPE,
|
||||||
@@ -97,7 +98,8 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = {
|
|||||||
],
|
],
|
||||||
"tasks": [
|
"tasks": [
|
||||||
("readonly", [TASKS_READONLY_SCOPE]),
|
("readonly", [TASKS_READONLY_SCOPE]),
|
||||||
("full", [TASKS_SCOPE]),
|
("manage", [TASKS_SCOPE]),
|
||||||
|
("full", []),
|
||||||
],
|
],
|
||||||
"contacts": [
|
"contacts": [
|
||||||
("readonly", [CONTACTS_READONLY_SCOPE]),
|
("readonly", [CONTACTS_READONLY_SCOPE]),
|
||||||
@@ -131,16 +133,43 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Actions denied at specific permission levels.
|
||||||
|
# Maps service -> level -> frozenset of denied action names.
|
||||||
|
# Levels not listed here (or services without entries) deny nothing.
|
||||||
|
SERVICE_DENIED_ACTIONS: Dict[str, Dict[str, FrozenSet[str]]] = {
|
||||||
|
"tasks": {
|
||||||
|
"manage": frozenset({"delete", "clear_completed"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_action_denied(service: str, action: str) -> bool:
|
||||||
|
"""Check whether *action* is denied for *service* under current permissions.
|
||||||
|
|
||||||
|
Returns ``False`` when granular permissions mode is not active, when the
|
||||||
|
service has no permission entry, or when the configured level does not
|
||||||
|
deny the action.
|
||||||
|
"""
|
||||||
|
if _PERMISSIONS is None:
|
||||||
|
return False
|
||||||
|
level = _PERMISSIONS.get(service)
|
||||||
|
if level is None:
|
||||||
|
return False
|
||||||
|
denied = SERVICE_DENIED_ACTIONS.get(service, {}).get(level, frozenset())
|
||||||
|
return action in denied
|
||||||
|
|
||||||
|
|
||||||
# Module-level state: parsed --permissions config
|
# Module-level state: parsed --permissions config
|
||||||
# Dict mapping service_name -> level_name, e.g. {"gmail": "organize"}
|
# Dict mapping service_name -> level_name, e.g. {"gmail": "organize"}
|
||||||
_PERMISSIONS: Optional[Dict[str, str]] = None
|
_PERMISSIONS: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
def set_permissions(permissions: Dict[str, str]) -> None:
|
def set_permissions(permissions: Optional[Dict[str, str]]) -> None:
|
||||||
"""Set granular permissions from parsed --permissions argument."""
|
"""Set granular permissions from parsed --permissions argument."""
|
||||||
global _PERMISSIONS
|
global _PERMISSIONS
|
||||||
_PERMISSIONS = permissions
|
_PERMISSIONS = permissions
|
||||||
logger.info("Granular permissions set: %s", permissions)
|
if permissions is not None:
|
||||||
|
logger.info("Granular permissions set: %s", permissions)
|
||||||
|
|
||||||
|
|
||||||
def get_permissions() -> Optional[Dict[str, str]]:
|
def get_permissions() -> Optional[Dict[str, str]]:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from googleapiclient.errors import HttpError # type: ignore
|
|||||||
from mcp import Resource
|
from mcp import Resource
|
||||||
|
|
||||||
from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider
|
from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider
|
||||||
|
from auth.permissions import is_action_denied
|
||||||
from auth.service_decorator import require_google_service
|
from auth.service_decorator import require_google_service
|
||||||
from core.server import server
|
from core.server import server
|
||||||
from core.utils import UserInputError, handle_http_errors
|
from core.utils import UserInputError, handle_http_errors
|
||||||
@@ -320,6 +321,11 @@ async def manage_task_list(
|
|||||||
f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
|
f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_action_denied("tasks", action):
|
||||||
|
raise UserInputError(
|
||||||
|
f"The '{action}' action is not allowed under the current permission level."
|
||||||
|
)
|
||||||
|
|
||||||
if action == "create":
|
if action == "create":
|
||||||
if not title:
|
if not title:
|
||||||
raise UserInputError("'title' is required for the 'create' action.")
|
raise UserInputError("'title' is required for the 'create' action.")
|
||||||
@@ -887,6 +893,11 @@ async def manage_task(
|
|||||||
f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
|
f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_action_denied("tasks", action):
|
||||||
|
raise UserInputError(
|
||||||
|
f"The '{action}' action is not allowed under the current permission level."
|
||||||
|
)
|
||||||
|
|
||||||
if action == "create":
|
if action == "create":
|
||||||
if status is not None:
|
if status is not None:
|
||||||
raise UserInputError("'status' is only supported for the 'update' action.")
|
raise UserInputError("'status' is only supported for the 'update' action.")
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
|
|||||||
|
|
||||||
from auth.permissions import (
|
from auth.permissions import (
|
||||||
get_scopes_for_permission,
|
get_scopes_for_permission,
|
||||||
|
is_action_denied,
|
||||||
parse_permissions_arg,
|
parse_permissions_arg,
|
||||||
|
set_permissions,
|
||||||
SERVICE_PERMISSION_LEVELS,
|
SERVICE_PERMISSION_LEVELS,
|
||||||
)
|
)
|
||||||
from auth.scopes import (
|
from auth.scopes import (
|
||||||
@@ -24,6 +26,8 @@ from auth.scopes import (
|
|||||||
GMAIL_COMPOSE_SCOPE,
|
GMAIL_COMPOSE_SCOPE,
|
||||||
DRIVE_READONLY_SCOPE,
|
DRIVE_READONLY_SCOPE,
|
||||||
DRIVE_SCOPE,
|
DRIVE_SCOPE,
|
||||||
|
TASKS_READONLY_SCOPE,
|
||||||
|
TASKS_SCOPE,
|
||||||
DRIVE_FILE_SCOPE,
|
DRIVE_FILE_SCOPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +72,11 @@ class TestParsePermissionsArg:
|
|||||||
with pytest.raises(ValueError, match="Unknown level"):
|
with pytest.raises(ValueError, match="Unknown level"):
|
||||||
parse_permissions_arg(["gmail:read:only"])
|
parse_permissions_arg(["gmail:read:only"])
|
||||||
|
|
||||||
|
def test_tasks_manage_is_valid_level(self):
|
||||||
|
"""tasks:manage should be accepted by parse_permissions_arg."""
|
||||||
|
result = parse_permissions_arg(["tasks:manage"])
|
||||||
|
assert result == {"tasks": "manage"}
|
||||||
|
|
||||||
|
|
||||||
class TestGetScopesForPermission:
|
class TestGetScopesForPermission:
|
||||||
"""Tests for get_scopes_for_permission() cumulative scope expansion."""
|
"""Tests for get_scopes_for_permission() cumulative scope expansion."""
|
||||||
@@ -116,3 +125,77 @@ class TestGetScopesForPermission:
|
|||||||
assert len(scopes) == len(set(scopes)), (
|
assert len(scopes) == len(set(scopes)), (
|
||||||
f"Duplicate scopes for {service}:{level_name}"
|
f"Duplicate scopes for {service}:{level_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_tasks_manage_includes_write_scope(self):
|
||||||
|
"""Manage level should cumulatively include readonly and write scopes."""
|
||||||
|
scopes = get_scopes_for_permission("tasks", "manage")
|
||||||
|
assert TASKS_SCOPE in scopes
|
||||||
|
assert TASKS_READONLY_SCOPE in scopes
|
||||||
|
|
||||||
|
def test_tasks_full_includes_write_scope(self):
|
||||||
|
"""Full level should include write and readonly scopes from lower levels."""
|
||||||
|
scopes = get_scopes_for_permission("tasks", "full")
|
||||||
|
assert TASKS_SCOPE in scopes
|
||||||
|
assert TASKS_READONLY_SCOPE in scopes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_permissions_state():
|
||||||
|
"""Ensure each test starts and ends with no active permissions."""
|
||||||
|
set_permissions(None)
|
||||||
|
yield
|
||||||
|
set_permissions(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsActionDenied:
|
||||||
|
"""Tests for is_action_denied() and SERVICE_DENIED_ACTIONS."""
|
||||||
|
|
||||||
|
def test_no_permissions_mode_allows_all(self):
|
||||||
|
"""Without granular permissions, no action is denied."""
|
||||||
|
set_permissions(None)
|
||||||
|
assert is_action_denied("tasks", "delete") is False
|
||||||
|
|
||||||
|
def test_tasks_full_allows_delete(self):
|
||||||
|
"""Full level should not deny delete."""
|
||||||
|
set_permissions({"tasks": "full"})
|
||||||
|
assert is_action_denied("tasks", "delete") is False
|
||||||
|
|
||||||
|
def test_tasks_manage_denies_delete(self):
|
||||||
|
"""Manage level should deny delete."""
|
||||||
|
set_permissions({"tasks": "manage"})
|
||||||
|
assert is_action_denied("tasks", "delete") is True
|
||||||
|
|
||||||
|
def test_tasks_manage_allows_create(self):
|
||||||
|
"""Manage level should allow create."""
|
||||||
|
set_permissions({"tasks": "manage"})
|
||||||
|
assert is_action_denied("tasks", "create") is False
|
||||||
|
|
||||||
|
def test_tasks_manage_allows_update(self):
|
||||||
|
"""Manage level should allow update."""
|
||||||
|
set_permissions({"tasks": "manage"})
|
||||||
|
assert is_action_denied("tasks", "update") is False
|
||||||
|
|
||||||
|
def test_tasks_manage_allows_move(self):
|
||||||
|
"""Manage level should allow move."""
|
||||||
|
set_permissions({"tasks": "manage"})
|
||||||
|
assert is_action_denied("tasks", "move") is False
|
||||||
|
|
||||||
|
def test_tasks_manage_denies_clear_completed(self):
|
||||||
|
"""Manage level should deny clear_completed."""
|
||||||
|
set_permissions({"tasks": "manage"})
|
||||||
|
assert is_action_denied("tasks", "clear_completed") is True
|
||||||
|
|
||||||
|
def test_tasks_full_allows_clear_completed(self):
|
||||||
|
"""Full level should not deny clear_completed."""
|
||||||
|
set_permissions({"tasks": "full"})
|
||||||
|
assert is_action_denied("tasks", "clear_completed") is False
|
||||||
|
|
||||||
|
def test_service_not_in_permissions_allows_all(self):
|
||||||
|
"""A service not listed in permissions should allow all actions."""
|
||||||
|
set_permissions({"gmail": "readonly"})
|
||||||
|
assert is_action_denied("tasks", "delete") is False
|
||||||
|
|
||||||
|
def test_service_without_denied_actions_allows_all(self):
|
||||||
|
"""A service with no SERVICE_DENIED_ACTIONS entry should allow all actions."""
|
||||||
|
set_permissions({"gmail": "readonly"})
|
||||||
|
assert is_action_denied("gmail", "delete") is False
|
||||||
|
|||||||
Reference in New Issue
Block a user