fix(security): F09 (Astra P1) — CRM-/MCP-Tools delegieren mit HMAC-Token statt toter Header
Check Cross-Plugin Imports / check (push) Has been cancelled

Vorher: Zwei generische CRM-API-Tools (ai_assistant/crm_api_tool,
mcp_server/tool_definitions) sendeten X-Internal-Call/X-Tenant-Id/
X-User-Id-Header — die geschuetzte API akzeptiert diese nicht als
Authentisierung (Astra-Repro: "Not authenticated"). Der vorhandene
Delegationstoken-Code (app/core/delegation_token.py, HMAC-SHA256,
max 60s) war komplett unverbunden (0 Aufrufer). Im Worker zeigte der
lokale Default-Host zudem auf den Worker selbst.

Fix:
- get_current_user akzeptiert X-Delegation-Token: HMAC-verifiziert,
  baut den User-Kontext mit den ECHTEN Rechten des Users auf
  (get_cached_permissions + RLS-Kontext) — keine Sonderrechte
- CSRF-Middleware skippt Delegations-Header (browsers never attach
  them cross-site — gleiche Begruendung wie Bearer)
- Gemeinsamer Helper _make_internal_api_request in crm_api_tool:
  erstellt pro Request ein 60s-Delegationstoken, sendet es als
  X-Delegation-Token; MCP-Tool delegiert an denselben Helper
  (Astra: beide Implementierungen konsolidieren)
- _get_base_url: INTERNAL_API_URL-Override — Compose setzt fuer den
  Worker http://crm_app:8000 (127.0.0.1 zeigte im Worker auf sich
  selbst)

Abnahme (Astra): Dieselbe Fachaktion ist fuer denselben Benutzer ueber
UI und Agent gleichermaassen erlaubt oder gesperrt — die Tools laufen
jetzt mit den echten User-Rechten durch denselben Auth-Pfad. Das
Audit-Naming (delegated_by) folgt mit dem transparency-Update.

Verifikation: test_api_tokens (inkl. 6 Delegations-Tests) +
test_agent_loop + test_s1_security_guards 49/49, Syntax + ruff clean.
This commit is contained in:
Agent Zero
2026-09-18 12:12:18 +02:00
parent fdc4e36d14
commit 421726700b
5 changed files with 120 additions and 46 deletions
@@ -20,12 +20,56 @@ _openapi_cache: dict[str, Any] | None = None
def _get_base_url() -> str:
"""Get the internal base URL for API calls."""
"""Get the internal base URL for API calls.
F09 (Astra P1): inside the API container this is 127.0.0.1; the
WORKER must reach the API service instead - 127.0.0.1 there points
at the worker itself. INTERNAL_API_URL overrides (compose sets it
to http://crm_app:PORT for the worker service).
"""
import os
override = os.environ.get("INTERNAL_API_URL")
if override:
return override.rstrip("/")
port = os.environ.get("PORT", "8000")
return f"http://127.0.0.1:{port}"
async def _make_internal_api_request(method, path, tenant_id, user_id, body=None):
"""F09 (Astra P1): authenticated internal API request.
Sends a short-lived HMAC-signed delegation token (max 60 s) instead
of the previous unauthenticated X-Internal-Call headers that the
protected API never accepted. The token acts ON BEHALF OF the user
- their real permissions apply (RBAC + RLS), no special rights.
"""
from app.core.delegation_token import create_delegation_token
token = create_delegation_token(
user_id=str(user_id),
tenant_id=str(tenant_id),
agent_id="crm-api-tool",
)
headers = {
"Content-Type": "application/json",
"X-Delegation-Token": token,
}
url = f"{_get_base_url()}{path}"
async with httpx.AsyncClient() as client:
if method == "GET":
return await client.get(url, headers=headers, timeout=30.0)
if method == "POST":
return await client.post(url, headers=headers, json=body, timeout=30.0)
if method == "PATCH":
return await client.patch(url, headers=headers, json=body, timeout=30.0)
if method == "PUT":
return await client.put(url, headers=headers, json=body, timeout=30.0)
if method == "DELETE":
return await client.delete(url, headers=headers, timeout=30.0)
raise ValueError(f"Unsupported method: {method}")
async def get_openapi_spec() -> dict[str, Any]:
"""Get the CRM OpenAPI spec, cached."""
global _openapi_cache
@@ -101,34 +145,15 @@ async def call_crm_api_handler(arguments: dict[str, Any], context: dict[str, Any
path = "/" + path
try:
base_url = _get_base_url()
# Get user context for auth
tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "")
# Create a DB session to resolve a valid session token for this user
# We'll use internal service-level auth bypass
headers = {
"Content-Type": "application/json",
"X-Internal-Call": "true",
"X-Tenant-Id": str(tenant_id),
"X-User-Id": str(user_id),
}
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
elif method == "POST":
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PATCH":
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PUT":
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "DELETE":
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
else:
return json.dumps({"error": f"Unsupported method: {method}"})
# F09 (Astra P1): authenticated request via short-lived delegation
# token - the acting user's real permissions apply.
resp = await _make_internal_api_request(
method, path, tenant_id=tenant_id, user_id=user_id, body=body
)
# Return response body (truncated if too large)
try:
@@ -12,7 +12,6 @@ import os
import uuid
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
@@ -95,30 +94,16 @@ async def _handler_call_crm_api(db: AsyncSession, arguments: dict[str, Any], con
path = "/" + path
try:
base_url = _get_base_url()
tenant_id = context.get("tenant_id", "")
user_id = context.get("user_id", "")
headers = {
"Content-Type": "application/json",
"X-Internal-Call": "true",
"X-Tenant-Id": str(tenant_id),
"X-User-Id": str(user_id),
}
# F09 (Astra P1): authenticated request via short-lived delegation
# token - the MCP session user's real permissions apply.
from app.plugins.builtins.ai_assistant.crm_api_tool import _make_internal_api_request
async with httpx.AsyncClient() as client:
if method == "GET":
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
elif method == "POST":
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PATCH":
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "PUT":
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
elif method == "DELETE":
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
else:
return {"error": f"Unsupported method: {method}"}
resp = await _make_internal_api_request(
method, path, tenant_id=str(tenant_id), user_id=str(user_id), body=body
)
try:
resp_data = resp.json()