feat(security): resource-type-aware authorization with fail-closed defaults

Add aegis_gitea_mcp.authz: classify every dispatched call (typed tools and
gitea_request) by resource type (repository/org/user_self/user_owned/
misc_global/admin/unknown) and enforce a type-specific rule in service-PAT
mode, on top of policy + WRITE_MODE. Every decision fails closed:

- org: signed-in user must be a verified org member (Gitea-checked).
- user_owned: owner must be the caller or a member org of the caller.
- user_self: token-owner-scoped endpoints denied (token is the bot's).
- admin: default-deny; allowed only with RAW_API_ALLOW_SENSITIVE opt-in AND a
  verified site admin.
- misc_global: reads allowed, writes denied.
- unknown / unverifiable: denied and audited.

Wire it into the server's service-PAT dispatch: repository calls keep the
existing per-user collaborator check; non-repo calls (previously blanket-denied)
now go through the resource-type gate, opening the org/user/admin surface
safely. Verification results are cached briefly (fail-closed: positives only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:09:50 +02:00
parent 2d7f12d0d0
commit 3392d8f69b
4 changed files with 629 additions and 21 deletions
+342
View File
@@ -0,0 +1,342 @@
"""Resource-type-aware authorization (fail-closed).
The public HTTP server runs in *service-PAT mode*: a privileged bot token makes
the actual Gitea calls while a per-user OAuth identity decides what that user is
allowed to reach. For repository-scoped calls the server verifies the user's
collaborator permission on ``owner/repo``. This module closes the rest of the
gap — the admin/user/org/misc surface that ``gitea_request`` can now reach — by
classifying each call by *resource type* and enforcing a type-specific rule.
Every decision fails closed: if a call cannot be classified, or a required
permission cannot be positively verified against Gitea, it is denied and audited.
Rules (enforced only in service-PAT mode; in pure-OAuth mode the user's own
token already scopes every call at Gitea):
* ``repository`` — per-user collaborator permission (handled by the server's
existing repository check; not re-implemented here).
* ``org`` — the signed-in user must be a verified member of the target org.
* ``user_self`` — token-owner-scoped endpoints (``/user``, ``/notifications``).
Denied in service-PAT mode: the data belongs to the bot, not the caller.
* ``user_owned`` — a resource owned by a named user/org (``/users/{name}``,
``/packages/{owner}``). Allowed only when the owner is the caller, or the
caller is a verified member of the owning org.
* ``misc_global`` — instance-wide, read-only utility endpoints (markdown render,
version, gitignore templates …). Reads allowed; writes fall to policy.
* ``admin`` — default deny. Allowed only when the operator has opted in
(``RAW_API_ALLOW_SENSITIVE``) *and* the signed-in user is a verified Gitea
site administrator.
* ``unknown`` — denied.
"""
from __future__ import annotations
import urllib.parse
from dataclasses import dataclass
from enum import Enum
import httpx
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.cache import BoundedTTLCache
from aegis_gitea_mcp.config import get_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import (
normalize_raw_endpoint,
parse_raw_repository,
raw_relative_segments,
raw_request_is_write,
)
class ResourceType(str, Enum):
"""Coarse resource classes used for authorization decisions."""
REPOSITORY = "repository"
ORG = "org"
USER_SELF = "user_self"
USER_OWNED = "user_owned"
MISC_GLOBAL = "misc_global"
ADMIN = "admin"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class ResourceClass:
"""Result of classifying a call by resource type."""
resource_type: ResourceType
is_write: bool
repository: str | None = None
org: str | None = None
owner: str | None = None
# Instance-wide, read-only utility prefixes: not owned by any user/org.
_MISC_GLOBAL_PREFIXES = frozenset(
{
"markdown",
"markup",
"version",
"gitignore",
"licenses",
"label",
"topics",
"nodeinfo",
"activitypub",
"miscellaneous",
"signing-key.gpg",
"settings",
}
)
# Token-owner-scoped prefixes ("me"/"my" endpoints).
_USER_SELF_PREFIXES = frozenset({"user", "notifications"})
def classify_raw_endpoint(method: str, endpoint: str) -> ResourceClass:
"""Classify a normalized raw ``/api/v1`` endpoint by resource type.
Args:
method: HTTP method (used only to set the read/write flag).
endpoint: A normalized ``/api/v1/...`` path.
Returns:
The resource classification; ``UNKNOWN`` when nothing matches (deny).
"""
is_write = raw_request_is_write(method, endpoint)
rel = raw_relative_segments(endpoint)
if not rel:
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
top = rel[0]
if top == "admin":
return ResourceClass(ResourceType.ADMIN, is_write)
if top in {"repos", "repositories"}:
repository = parse_raw_repository(endpoint)
# repository is None for cross-repo endpoints (search/issues) — those
# cannot be scoped to a single owner/repo and so fail closed downstream.
return ResourceClass(ResourceType.REPOSITORY, is_write, repository=repository)
if top in {"orgs", "org"}:
org = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.ORG, is_write, org=org)
if top == "users":
owner = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
if top == "packages":
owner = rel[1] if len(rel) >= 2 else None
return ResourceClass(ResourceType.USER_OWNED, is_write, owner=owner)
if top in _USER_SELF_PREFIXES:
return ResourceClass(ResourceType.USER_SELF, is_write)
if top in _MISC_GLOBAL_PREFIXES:
return ResourceClass(ResourceType.MISC_GLOBAL, is_write)
return ResourceClass(ResourceType.UNKNOWN, is_write)
def classify_tool(tool_name: str, arguments: dict[str, object]) -> ResourceClass:
"""Classify a dispatched tool call (typed tool or ``gitea_request``).
Repository-scoped typed tools are handled by the server's repository check,
so this primarily classifies the non-repo surface that this module gates.
"""
if tool_name == "gitea_request":
method = str(arguments.get("method", "GET"))
path = str(arguments.get("path", ""))
try:
endpoint = normalize_raw_endpoint(path)
except ValueError:
return ResourceClass(ResourceType.UNKNOWN, is_write=True)
return classify_raw_endpoint(method, endpoint)
if tool_name == "list_org_repositories":
org = arguments.get("org")
return ResourceClass(
ResourceType.ORG, is_write=False, org=org if isinstance(org, str) else None
)
if tool_name == "list_organizations":
# Backed by /user/orgs: token-owner-scoped, not attributable to the caller
# in service-PAT mode.
return ResourceClass(ResourceType.USER_SELF, is_write=False)
# Any other non-repository tool is unrecognized for the purpose of this gate.
return ResourceClass(ResourceType.UNKNOWN, is_write=False)
# Bounded, short-TTL caches for positive verification results (fail-closed:
# only successful checks are cached).
_org_membership_cache: BoundedTTLCache[str, bool] | None = None
_site_admin_cache: BoundedTTLCache[str, bool] | None = None
def _get_org_membership_cache() -> BoundedTTLCache[str, bool]:
global _org_membership_cache
if _org_membership_cache is None:
ttl = get_settings().repo_authz_cache_ttl_seconds
_org_membership_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
return _org_membership_cache
def _get_site_admin_cache() -> BoundedTTLCache[str, bool]:
global _site_admin_cache
if _site_admin_cache is None:
ttl = get_settings().repo_authz_cache_ttl_seconds
_site_admin_cache = BoundedTTLCache(ttl_seconds=ttl, max_size=2048)
return _site_admin_cache
def reset_authz_caches() -> None:
"""Reset authorization caches (primarily for tests)."""
global _org_membership_cache, _site_admin_cache
_org_membership_cache = None
_site_admin_cache = None
async def _service_get(path: str) -> httpx.Response | None:
"""GET ``path`` on Gitea with the service PAT; None on transport failure."""
settings = get_settings()
token = settings.gitea_token.strip()
if not token:
return None
url = f"{settings.gitea_base_url}{path}"
try:
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
return await client.get(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
except httpx.RequestError:
return None
async def verify_org_membership(*, org: str, user_login: str) -> bool:
"""Return True only if ``user_login`` is a verified member of ``org``.
Fails closed: any transport error, non-204 response, or missing identity
yields False.
"""
if not org or not user_login or user_login == "unknown":
return False
cache_key = f"{org.lower()}:{user_login.lower()}"
cache = _get_org_membership_cache()
if cache.get(cache_key) is True:
return True
encoded_org = urllib.parse.quote(org, safe="")
encoded_user = urllib.parse.quote(user_login, safe="")
response = await _service_get(f"/api/v1/orgs/{encoded_org}/members/{encoded_user}")
if response is not None and response.status_code == 204:
cache.set(cache_key, True)
return True
return False
async def verify_site_admin(*, user_login: str) -> bool:
"""Return True only if ``user_login`` is a verified Gitea site administrator.
Requires the service PAT to have admin visibility (so ``is_admin`` is
returned). Fails closed on any error or when the flag is not positively True.
"""
if not user_login or user_login == "unknown":
return False
cache_key = user_login.lower()
cache = _get_site_admin_cache()
if cache.get(cache_key) is True:
return True
encoded_user = urllib.parse.quote(user_login, safe="")
response = await _service_get(f"/api/v1/users/{encoded_user}")
if response is None or response.status_code != 200:
return False
try:
payload = response.json()
except ValueError:
return False
if isinstance(payload, dict) and payload.get("is_admin") is True:
cache.set(cache_key, True)
return True
return False
async def authorize_non_repository_access(
*,
classification: ResourceClass,
user_login: str,
tool_name: str,
correlation_id: str | None = None,
) -> None:
"""Enforce the resource-type rule for a non-repository call (service-PAT mode).
Raises:
ToolError: with status 403 when the call is denied. The repository type
is intentionally not handled here — the server's existing per-user
collaborator check owns it.
"""
audit = get_audit_logger()
settings = get_settings()
login = (user_login or "").strip()
def _deny(reason: str) -> ToolError:
audit.log_access_denied(
tool_name=tool_name,
repository=classification.repository,
reason=f"resource_authz:{classification.resource_type.value}:{reason}",
correlation_id=correlation_id,
)
return ToolError(
f"Access denied for {classification.resource_type.value} resource: {reason}",
status_code=403,
)
rtype = classification.resource_type
if rtype == ResourceType.REPOSITORY:
# Reached only when a repo-scoped path could not be parsed to owner/repo
# (e.g. cross-repo search). Cannot verify per-user permission -> deny.
raise _deny("repository could not be determined")
if rtype == ResourceType.ORG:
if not classification.org:
raise _deny("organization not specified")
if await verify_org_membership(org=classification.org, user_login=login):
return
raise _deny("user is not a verified member of the organization")
if rtype == ResourceType.USER_OWNED:
owner = (classification.owner or "").strip()
if not owner:
raise _deny("resource owner not specified")
if owner.lower() == login.lower() and login:
return
# The owner may be an organization the caller belongs to.
if await verify_org_membership(org=owner, user_login=login):
return
raise _deny("resource owner is neither the caller nor a member org")
if rtype == ResourceType.USER_SELF:
# Token-owner-scoped data; in service-PAT mode the token is the bot's, so
# the result cannot be attributed to the caller.
raise _deny("token-owner-scoped endpoint is not available in service-PAT mode")
if rtype == ResourceType.MISC_GLOBAL:
if not classification.is_write:
return
# Writes to global utility endpoints are not part of the safe surface.
raise _deny("write to a global endpoint is not permitted")
if rtype == ResourceType.ADMIN:
if not settings.raw_api_allow_sensitive:
raise _deny("admin surface is disabled (set RAW_API_ALLOW_SENSITIVE=true to opt in)")
if await verify_site_admin(user_login=login):
return
raise _deny("user is not a verified site administrator")
raise _deny("unclassified resource")
+23 -21
View File
@@ -19,6 +19,7 @@ from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse,
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from aegis_gitea_mcp.audit import get_audit_logger from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.authz import authorize_non_repository_access, classify_tool
from aegis_gitea_mcp.automation import AutomationError, AutomationManager from aegis_gitea_mcp.automation import AutomationError, AutomationManager
from aegis_gitea_mcp.cache import BoundedTTLCache from aegis_gitea_mcp.cache import BoundedTTLCache
from aegis_gitea_mcp.config import get_settings from aegis_gitea_mcp.config import get_settings
@@ -1143,33 +1144,34 @@ async def _execute_tool_call(
raise HTTPException(status_code=401, detail="Missing authenticated user token context") raise HTTPException(status_code=401, detail="Missing authenticated user token context")
if settings.gitea_token.strip(): if settings.gitea_token.strip():
if not repository: user_login = get_gitea_user_login() or ""
# list_repositories is not repo-scoped; the handler scopes it to if repository:
# the authenticated user's own repositories instead. Every other # Repository-scoped: verify the signed-in user's collaborator
# tool requires a repository target so per-user permission can be # permission before the privileged service PAT is used.
# verified before the privileged service PAT is used.
if tool_name != "list_repositories":
audit.log_access_denied(
tool_name=tool_name,
reason="service_pat_requires_repository_target",
correlation_id=correlation_id,
)
raise HTTPException(
status_code=403,
detail=(
"Service PAT mode requires a repository target so per-user "
"permission can be verified."
),
)
else:
user_login = get_gitea_user_login()
await _verify_user_repository_access( await _verify_user_repository_access(
repository=repository, repository=repository,
required_scope=required_scope, required_scope=required_scope,
user_login=user_login or "", user_login=user_login,
correlation_id=correlation_id, correlation_id=correlation_id,
tool_name=tool_name, tool_name=tool_name,
) )
elif tool_name == "list_repositories":
# Not repo-scoped; the handler scopes it to the authenticated
# user's own repositories.
pass
else:
# Non-repository call (org/user/admin/misc, incl. gitea_request):
# classify by resource type and enforce the fail-closed rule.
classification = classify_tool(tool_name, arguments)
try:
await authorize_non_repository_access(
classification=classification,
user_login=user_login,
tool_name=tool_name,
correlation_id=correlation_id,
)
except ToolError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
# In OAuth mode, Gitea OIDC access_tokens can't call the Gitea REST API # In OAuth mode, Gitea OIDC access_tokens can't call the Gitea REST API
# (they only carry OIDC scopes). If a service PAT is configured via # (they only carry OIDC scopes). If a service PAT is configured via
+3
View File
@@ -7,6 +7,7 @@ import pytest
from aegis_gitea_mcp.audit import reset_audit_logger from aegis_gitea_mcp.audit import reset_audit_logger
from aegis_gitea_mcp.auth import reset_validator from aegis_gitea_mcp.auth import reset_validator
from aegis_gitea_mcp.authz import reset_authz_caches
from aegis_gitea_mcp.config import reset_settings from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.oauth import reset_oauth_validator from aegis_gitea_mcp.oauth import reset_oauth_validator
from aegis_gitea_mcp.oauth_flow import reset_oauth_client_registry from aegis_gitea_mcp.oauth_flow import reset_oauth_client_registry
@@ -27,6 +28,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
reset_oauth_validator() reset_oauth_validator()
reset_oauth_client_registry() reset_oauth_client_registry()
reset_repo_authz_cache() reset_repo_authz_cache()
reset_authz_caches()
reset_policy_engine() reset_policy_engine()
reset_rate_limiter() reset_rate_limiter()
reset_metrics_registry() reset_metrics_registry()
@@ -45,6 +47,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
reset_oauth_validator() reset_oauth_validator()
reset_oauth_client_registry() reset_oauth_client_registry()
reset_repo_authz_cache() reset_repo_authz_cache()
reset_authz_caches()
reset_policy_engine() reset_policy_engine()
reset_rate_limiter() reset_rate_limiter()
reset_metrics_registry() reset_metrics_registry()
+261
View File
@@ -0,0 +1,261 @@
"""Tests for resource-type-aware authorization (fail-closed)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aegis_gitea_mcp import authz
from aegis_gitea_mcp.authz import (
ResourceClass,
ResourceType,
authorize_non_repository_access,
classify_raw_endpoint,
classify_tool,
verify_org_membership,
verify_site_admin,
)
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import normalize_raw_endpoint
@pytest.fixture
def authz_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Service-PAT-mode settings used by the authorization layer."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
def _endpoint(path: str) -> str:
return normalize_raw_endpoint(path)
# --- Classification ---------------------------------------------------------
@pytest.mark.parametrize(
("method", "path", "rtype", "ident_field", "ident_value"),
[
("GET", "/repos/acme/app/pulls/1", ResourceType.REPOSITORY, "repository", "acme/app"),
("GET", "/repos/issues/search", ResourceType.REPOSITORY, "repository", None),
("GET", "/orgs/acme/repos", ResourceType.ORG, "org", "acme"),
("GET", "/users/bob/repos", ResourceType.USER_OWNED, "owner", "bob"),
("GET", "/packages/bob/pypi", ResourceType.USER_OWNED, "owner", "bob"),
("GET", "/user/repos", ResourceType.USER_SELF, "repository", None),
("GET", "/notifications", ResourceType.USER_SELF, "repository", None),
("GET", "/markdown", ResourceType.MISC_GLOBAL, "repository", None),
("GET", "/version", ResourceType.MISC_GLOBAL, "repository", None),
("DELETE", "/admin/users/bob", ResourceType.ADMIN, "repository", None),
],
)
def test_classify_raw_endpoint(
method: str, path: str, rtype: ResourceType, ident_field: str, ident_value: str | None
) -> None:
result = classify_raw_endpoint(method, _endpoint(path))
assert result.resource_type is rtype
assert getattr(result, ident_field) == ident_value
def test_classify_tool_maps_typed_tools() -> None:
assert classify_tool("list_org_repositories", {"org": "acme"}).resource_type is ResourceType.ORG
assert classify_tool("list_org_repositories", {"org": "acme"}).org == "acme"
assert classify_tool("list_organizations", {}).resource_type is ResourceType.USER_SELF
# An unrecognized non-repo tool is UNKNOWN (deny).
assert classify_tool("something_new", {}).resource_type is ResourceType.UNKNOWN
def test_classify_tool_gitea_request_uses_path() -> None:
cls = classify_tool("gitea_request", {"method": "GET", "path": "/orgs/acme/repos"})
assert cls.resource_type is ResourceType.ORG
assert cls.org == "acme"
def test_classify_tool_gitea_request_traversal_is_unknown() -> None:
cls = classify_tool("gitea_request", {"method": "GET", "path": "/repos/../../admin"})
assert cls.resource_type is ResourceType.UNKNOWN
# --- Decision matrix (verification mocked) ----------------------------------
async def test_org_member_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_org_nonmember_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert exc_info.value.status_code == 403
async def test_user_owned_self_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="alice")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_owned_member_org_allowed(
authz_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="acme")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_owned_other_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="bob")
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_self_denied_in_service_pat_mode(authz_env: None) -> None:
cls = ResourceClass(ResourceType.USER_SELF, is_write=False)
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert "token-owner-scoped" in str(exc_info.value.detail)
async def test_misc_global_read_allowed_write_denied(authz_env: None) -> None:
read_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=False)
await authorize_non_repository_access(
classification=read_cls, user_login="alice", tool_name="gitea_request"
)
write_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=True)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=write_cls, user_login="alice", tool_name="gitea_request"
)
async def test_admin_denied_without_opt_in(authz_env: None) -> None:
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert "RAW_API_ALLOW_SENSITIVE" in str(exc_info.value.detail)
async def test_admin_allowed_only_for_site_admin_with_opt_in(
authz_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true")
reset_settings()
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
await authorize_non_repository_access(
classification=cls, user_login="root", tool_name="gitea_request"
)
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=False))
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_unknown_resource_denied(authz_env: None) -> None:
cls = ResourceClass(ResourceType.UNKNOWN, is_write=False)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_repository_without_target_denied(authz_env: None) -> None:
"""A repo-typed call that could not be scoped to owner/repo fails closed."""
cls = ResourceClass(ResourceType.REPOSITORY, is_write=False, repository=None)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
# --- Gitea verification helpers (fail-closed) -------------------------------
def _patch_service_response(status_code: int, json_value: object = None) -> Any:
response = MagicMock()
response.status_code = status_code
response.json.return_value = json_value
return response
def _patched_client(response: object) -> Any:
patcher = patch("aegis_gitea_mcp.authz.httpx.AsyncClient")
mock_client_cls = patcher.start()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
return patcher
async def test_verify_org_membership_204_true(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(204))
try:
assert await verify_org_membership(org="acme", user_login="alice") is True
finally:
patcher.stop()
async def test_verify_org_membership_404_false(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(404))
try:
assert await verify_org_membership(org="acme", user_login="alice") is False
finally:
patcher.stop()
async def test_verify_org_membership_unknown_user_false(authz_env: None) -> None:
assert await verify_org_membership(org="acme", user_login="unknown") is False
async def test_verify_site_admin_true_only_when_flag_set(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(200, {"is_admin": True}))
try:
assert await verify_site_admin(user_login="root") is True
finally:
patcher.stop()
async def test_verify_site_admin_false_when_flag_absent(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(200, {"is_admin": False}))
try:
assert await verify_site_admin(user_login="alice") is False
finally:
patcher.stop()
async def test_verify_site_admin_non_200_false(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(403, {}))
try:
assert await verify_site_admin(user_login="alice") is False
finally:
patcher.stop()