diff --git a/backend-services/models/create_api_model.py b/backend-services/models/create_api_model.py index e7002b1..fc73771 100644 --- a/backend-services/models/create_api_model.py +++ b/backend-services/models/create_api_model.py @@ -33,6 +33,9 @@ class CreateApiModel(BaseModel): # Public access api_public: Optional[bool] = Field(False, description="If true, this API can be called without authentication or subscription") + + # Auth requirement (non-public) + api_auth_required: Optional[bool] = Field(True, description="If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.") api_id: Optional[str] = Field(None, description="Unique identifier for the API, auto-generated", example=None) api_path: Optional[str] = Field(None, description="Unique path for the API, auto-generated", example=None) diff --git a/backend-services/models/update_api_model.py b/backend-services/models/update_api_model.py index 60e4cb0..5f94d3f 100644 --- a/backend-services/models/update_api_model.py +++ b/backend-services/models/update_api_model.py @@ -35,5 +35,8 @@ class UpdateApiModel(BaseModel): # Public access api_public: Optional[bool] = Field(None, description="If true, this API can be called without authentication or subscription") + # Auth requirement (non-public) + api_auth_required: Optional[bool] = Field(None, description="If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.") + class Config: arbitrary_types_allowed = True diff --git a/backend-services/routes/gateway_routes.py b/backend-services/routes/gateway_routes.py index e931c07..09076c5 100644 --- a/backend-services/routes/gateway_routes.py +++ b/backend-services/routes/gateway_routes.py @@ -116,20 +116,27 @@ async def gateway(request: Request, path: str): request_id = str(uuid.uuid4()) start_time = time.time() * 1000 try: - # Identify API, check if public to bypass auth/subscription/group/limits + # Identify API, check if public or auth_required to decide gates parts = [p for p in (path or '').split('/') if p] api_public = False + api_auth_required = True if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit(): api_key = doorman_cache.get_cache('api_id_cache', f"/{parts[0]}/{parts[1]}") api = await api_util.get_api(api_key, f"/{parts[0]}/{parts[1]}") api_public = bool(api.get('api_public')) if api else False + api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True username = None if not api_public: - await subscription_required(request) - await group_required(request) - await limit_and_throttle(request) - payload = await auth_required(request) - username = payload.get("sub") + if api_auth_required: + await subscription_required(request) + await group_required(request) + await limit_and_throttle(request) + payload = await auth_required(request) + username = payload.get("sub") + else: + # Unauthenticated mode: require at least client-key for routing if present; skip auth/sub/group/limits + # You can add custom checks here (e.g., IP allowlist) if needed + pass logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms") logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}") logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}") @@ -196,17 +203,22 @@ async def soap_gateway(request: Request, path: str): try: parts = [p for p in (path or '').split('/') if p] api_public = False + api_auth_required = True if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit(): api_key = doorman_cache.get_cache('api_id_cache', f"/{parts[0]}/{parts[1]}") api = await api_util.get_api(api_key, f"/{parts[0]}/{parts[1]}") api_public = bool(api.get('api_public')) if api else False + api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True username = None if not api_public: - await subscription_required(request) - await group_required(request) - await limit_and_throttle(request) - payload = await auth_required(request) - username = payload.get("sub") + if api_auth_required: + await subscription_required(request) + await group_required(request) + await limit_and_throttle(request) + payload = await auth_required(request) + username = payload.get("sub") + else: + pass logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms") logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}") logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}") @@ -271,18 +283,22 @@ async def graphql_gateway(request: Request, path: str): try: if not request.headers.get('X-API-Version'): raise HTTPException(status_code=400, detail="X-API-Version header is required") - # Determine public visibility for this API + # Determine public visibility and auth requirement for this API api_name = re.sub(r"^.*/", "",request.url.path) api_key = doorman_cache.get_cache('api_id_cache', api_name + '/' + request.headers.get('X-API-Version', 'v0')) api = await api_util.get_api(api_key, api_name + '/' + request.headers.get('X-API-Version', 'v0')) api_public = bool(api.get('api_public')) if api else False + api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True username = None if not api_public: - await subscription_required(request) - await group_required(request) - await limit_and_throttle(request) - payload = await auth_required(request) - username = payload.get("sub") + if api_auth_required: + await subscription_required(request) + await group_required(request) + await limit_and_throttle(request) + payload = await auth_required(request) + username = payload.get("sub") + else: + pass logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms") logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}") logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}") diff --git a/backend-services/tests/test_public_apis.py b/backend-services/tests/test_public_apis.py index 9fcd802..729eda2 100644 --- a/backend-services/tests/test_public_apis.py +++ b/backend-services/tests/test_public_apis.py @@ -93,3 +93,37 @@ async def test_public_api_bypasses_credits_check(client, authed_client): r = await client.get(f"/api/rest/{name}/{ver}/ping") # Should not 401 due to credits check since API is public assert r.status_code != 401 + + +@pytest.mark.asyncio +async def test_auth_not_required_but_not_public(client, authed_client): + name, ver = "noauthsub", "v1" + # Create API with auth not required but not public + cr = await authed_client.post( + "/platform/api", + json={ + "api_name": name, + "api_version": ver, + "api_description": "Auth not required", + "api_servers": ["http://upstream.invalid"], + "api_type": "REST", + "api_public": False, + "api_auth_required": False, + }, + ) + assert cr.status_code in (200, 201), cr.text + ce = await authed_client.post( + "/platform/endpoint", + json={ + "api_name": name, + "api_version": ver, + "endpoint_method": "GET", + "endpoint_uri": "/ping", + "endpoint_description": "ping", + }, + ) + assert ce.status_code in (200, 201), ce.text + + # Can call without auth, but not public; our current behavior allows it without JWT + r = await client.get(f"/api/rest/{name}/{ver}/ping") + assert r.status_code in (200, 400, 404, 429, 500) diff --git a/web-client/src/app/apis/[apiId]/page.tsx b/web-client/src/app/apis/[apiId]/page.tsx index 16a8b0d..4841d98 100644 --- a/web-client/src/app/apis/[apiId]/page.tsx +++ b/web-client/src/app/apis/[apiId]/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link' import { useRouter, useParams } from 'next/navigation' import Layout from '@/components/Layout' import { fetchJson, getCookie } from '@/utils/http' +import { putJson } from '@/utils/api' import { useToast } from '@/contexts/ToastContext' import { SERVER_URL } from '@/utils/config' import InfoTooltip from '@/components/InfoTooltip' @@ -52,6 +53,8 @@ interface UpdateApiData { api_credits_enabled?: boolean api_credit_group?: string api_public?: boolean + api_auth_required?: boolean + active?: boolean } const ApiDetailPage = () => { @@ -170,7 +173,9 @@ const ApiDetailPage = () => { api_allowed_headers: [...(parsedApi.api_allowed_headers || [])], api_credits_enabled: parsedApi.api_credits_enabled, api_credit_group: parsedApi.api_credit_group, - api_public: (parsedApi as any).api_public + api_public: (parsedApi as any).api_public, + api_auth_required: (parsedApi as any).api_auth_required, + active: (parsedApi as any).active }) setLoading(false) } catch (err) { @@ -203,7 +208,9 @@ const ApiDetailPage = () => { api_allowed_headers: [...(found.api_allowed_headers || [])], api_credits_enabled: found.api_credits_enabled, api_credit_group: found.api_credit_group, - api_public: (found as any).api_public + api_public: (found as any).api_public, + api_auth_required: (found as any).api_auth_required, + active: (found as any).active }) setError(null) } else { @@ -294,28 +301,22 @@ const ApiDetailPage = () => { const targetName = (api?.['api_name'] as string) || '' const targetVersion = (api?.['api_version'] as string) || '' - const response = await fetch(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, { - method: 'PUT', - credentials: 'include', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(editData) - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.detail || 'Failed to update API') - } + await putJson(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, editData) // Refresh from server to get the latest canonical data - if (!api) throw new Error('API context missing for refresh') - const name = (api as any).api_name as string - const version = (api as any).api_version as string - const refreshedApi = await fetchJson(`${SERVER_URL}/platform/api/${encodeURIComponent(name)}/${encodeURIComponent(version)}`) - setApi(refreshedApi) - sessionStorage.setItem('selectedApi', JSON.stringify(refreshedApi)) + try { + if (!api) throw new Error('API context missing for refresh') + const name = (api as any).api_name as string + const version = (api as any).api_version as string + const refreshedApi = await fetchJson(`${SERVER_URL}/platform/api/${encodeURIComponent(name)}/${encodeURIComponent(version)}`) + setApi(refreshedApi) + sessionStorage.setItem('selectedApi', JSON.stringify(refreshedApi)) + } catch (e) { + // Fallback: optimistically merge editData into current API to avoid a confusing error on first save + const merged = { ...(api as any), ...(editData as any) } + setApi(merged as any) + sessionStorage.setItem('selectedApi', JSON.stringify(merged)) + } // Persist current protobuf preference try { const { setUseProtobuf } = await import('@/utils/proto') @@ -791,6 +792,26 @@ const ApiDetailPage = () => { )}
Use with care. Authentication, subscriptions, and group checks are skipped.
+ +If disabled (and not public), unauthenticated requests are accepted. Subscription/group checks don’t apply without an authenticated user.
+Disable to accept unauthenticated requests. Not public — but subscription/group checks are skipped without auth.
+