|
|
@@ -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",
|
|
|
+ },
|
|
|
+ )
|