Переглянути джерело

feat: enforce gateway scopes and authz

Jax Docker 1 місяць тому
батько
коміт
c92347c897

+ 3 - 0
README.md

@@ -1021,6 +1021,8 @@ Gateway API Key auth:
 - API keys are stored as SHA-256 hashes. The raw key is only returned once at creation.
 - When auth is enabled and no API key exists yet, the first `POST /gateway/api-keys` is allowed as bootstrap.
 - API keys can be `active`, `disabled`, or `revoked`; only `active` keys are accepted.
+- If an API key has `scopes`, gateway checks them before proxying. Use `*`, `gateway:agents:*`, or exact permissions such as `gateway:agents:read`.
+- Set `AGENT_PLATFORM_AUTHZ_REQUIRED=true` to require `x-user-id` and call `auth-service` `/auth/permissions/check` for the derived permission.
 
 Create an API key:
 
@@ -1028,6 +1030,7 @@ Create an API key:
 $body = @{
   tenant_id = "t1"
   name = "local-dev"
+  scopes = "gateway:agents:* gateway:runtime:read"
 } | ConvertTo-Json
 
 $created = Invoke-RestMethod `

+ 1 - 0
deployments/docker/.env.example

@@ -3,6 +3,7 @@ AGENT_PLATFORM_PROVIDER_API_KEY=replace-me
 AGENT_PLATFORM_DEFAULT_MODEL=gpt-4o-mini
 AGENT_PLATFORM_MAX_TIMEOUT_SECONDS=30
 AGENT_PLATFORM_AUTH_REQUIRED=false
+AGENT_PLATFORM_AUTHZ_REQUIRED=false
 AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS=1
 AGENT_PLATFORM_WORKER_LEASE_SECONDS=300
 AGENT_PLATFORM_SCHEDULER_WORKER_CLAIM_LIMIT=20

+ 1 - 0
deployments/docker/docker-compose.yml

@@ -493,6 +493,7 @@ services:
       AGENT_PLATFORM_AUTH_SERVICE_URL: http://auth-service:8014
       AGENT_PLATFORM_SCHEDULER_SERVICE_URL: http://scheduler-service:8015
       AGENT_PLATFORM_AUTH_REQUIRED: ${AGENT_PLATFORM_AUTH_REQUIRED:-false}
+      AGENT_PLATFORM_AUTHZ_REQUIRED: ${AGENT_PLATFORM_AUTHZ_REQUIRED:-false}
     ports:
       - "8000:8000"
     volumes:

+ 3 - 0
services/api-gateway/app/bootstrap/settings.py

@@ -23,4 +23,7 @@ class ApiGatewaySettings(ServiceSettings):
     proxy_timeout_seconds: float = 30.0
     downstream_health_timeout_seconds: float = 2.0
     auth_required: bool = False
+    authz_required: bool = False
     api_key_header_name: str = "x-api-key"
+    user_id_header_name: str = "x-user-id"
+    authz_timeout_seconds: float = 2.0

+ 94 - 0
services/api-gateway/app/infrastructure/request_context.py

@@ -3,6 +3,7 @@ from datetime import datetime
 from time import perf_counter
 from uuid import uuid4
 
+import httpx
 from fastapi import Request, Response
 from starlette.responses import JSONResponse
 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
@@ -22,6 +23,7 @@ class GatewayRequestContext:
     tenant_id: str
     started_perf_counter: float
     api_key_id: str | None = None
+    user_id: str | None = None
     target_service: str | None = None
     target_url: str | None = None
 
@@ -145,6 +147,30 @@ def authenticate_gateway_request(request: Request) -> Response | None:
             )
         context.tenant_id = entity.tenant_id
         context.api_key_id = entity.id
+        context.user_id = request.headers.get(settings.user_id_header_name)
+        permission = derive_gateway_permission(request)
+        if permission is not None and not api_key_scope_allows(
+            scopes=entity.scopes,
+            permission=permission,
+        ):
+            return JSONResponse(
+                status_code=403,
+                content={"detail": "api key scope denied", "permission": permission},
+            )
+        if settings.authz_required:
+            if context.user_id is None:
+                return JSONResponse(
+                    status_code=401,
+                    content={"detail": "missing user id"},
+                )
+            authz_response = check_auth_service_permission(
+                settings=settings,
+                tenant_id=entity.tenant_id,
+                user_id=context.user_id,
+                permission=permission or "gateway:access",
+            )
+            if authz_response is not None:
+                return authz_response
         ApiKeyRepository(db).touch_last_used_time(api_key_id=entity.id)
     finally:
         db.close()
@@ -161,3 +187,71 @@ def is_initial_api_key_bootstrap_request(request: Request) -> bool:
         return not ApiKeyRepository(db).has_any()
     finally:
         db.close()
+
+
+def derive_gateway_permission(request: Request) -> str | None:
+    if not request.url.path.startswith("/gateway/"):
+        return None
+    path_parts = [part for part in request.url.path.split("/") if part]
+    if len(path_parts) < 2:
+        return "gateway:access"
+    if path_parts[1] in {"services", "api-keys", "audits"}:
+        resource = path_parts[1]
+    else:
+        resource = path_parts[1].replace("_", "-")
+    action = "read" if request.method.upper() in {"GET", "HEAD", "OPTIONS"} else "write"
+    return f"gateway:{resource}:{action}"
+
+
+def api_key_scope_allows(*, scopes: str | None, permission: str) -> bool:
+    if scopes is None or not scopes.strip():
+        return True
+    scope_values = parse_scope_values(scopes)
+    if "*" in scope_values or permission in scope_values:
+        return True
+    resource_prefix = permission.rsplit(":", 1)[0]
+    return f"{resource_prefix}:*" in scope_values
+
+
+def parse_scope_values(scopes: str) -> set[str]:
+    normalized = scopes.replace(",", " ").replace("\n", " ")
+    return {item.strip() for item in normalized.split(" ") if item.strip()}
+
+
+def check_auth_service_permission(
+    *,
+    settings: ApiGatewaySettings,
+    tenant_id: str,
+    user_id: str,
+    permission: str,
+) -> Response | None:
+    try:
+        with httpx.Client(timeout=settings.authz_timeout_seconds) as client:
+            response = client.post(
+                f"{settings.auth_service_url.rstrip('/')}/auth/permissions/check",
+                json={
+                    "tenant_id": tenant_id,
+                    "user_id": user_id,
+                    "permission": permission,
+                },
+            )
+            response.raise_for_status()
+            payload = response.json()
+    except (httpx.HTTPError, ValueError) as exc:
+        return JSONResponse(
+            status_code=503,
+            content={"detail": "auth service permission check failed", "error": str(exc)},
+        )
+
+    allowed = payload.get("allowed")
+    if allowed is True:
+        return None
+    reason = payload.get("reason")
+    return JSONResponse(
+        status_code=403,
+        content={
+            "detail": "permission denied",
+            "permission": permission,
+            "reason": reason if isinstance(reason, str) else "denied",
+        },
+    )