fixes and auth not required

This commit is contained in:
seniorswe
2025-09-28 23:18:22 -04:00
parent 89a0c2996c
commit fdc67afd2d
7 changed files with 168 additions and 57 deletions

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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 = () => {
)}
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">Use with care. Authentication, subscriptions, and group checks are skipped.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auth Required</label>
{isEditing ? (
<div className="flex items-center">
<input
type="checkbox"
checked={!!(editData as any).api_auth_required}
onChange={(e) => handleInputChange('api_auth_required' as any, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Require platform auth (JWT) for this API</label>
</div>
) : (
<span className={`badge ${((api as any).api_auth_required ?? true) ? 'badge-primary' : 'badge-secondary'}`}>
{((api as any).api_auth_required ?? true) ? 'Auth Required' : 'No Auth'}
</span>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">If disabled (and not public), unauthenticated requests are accepted. Subscription/group checks dont apply without an authenticated user.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credits Enabled

View File

@@ -28,6 +28,7 @@ const AddApiPage = () => {
api_credits_enabled: false,
api_credit_group: '',
active: true,
api_auth_required: true,
// Frontend-only preference; stored in localStorage per API
use_protobuf: false,
// kept for future use; backend ignores unknown fields
@@ -300,6 +301,26 @@ const AddApiPage = () => {
<FormHelp docHref="/docs/using-fields.html#api-config">Set credits, auth header mapping, and validations.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Auth Required
</label>
<div className="flex items-center">
<input
id="api_auth_required"
name="api_auth_required"
type="checkbox"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
checked={(formData as any).api_auth_required}
onChange={handleChange}
disabled={loading}
/>
<label htmlFor="api_auth_required" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Require platform auth (JWT) for this API
</label>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable to accept unauthenticated requests. Not public — but subscription/group checks are skipped without auth.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">

View File

@@ -5,24 +5,37 @@ export function getCookie(name: string): string | null {
}
export async function fetchJson<T = any>(url: string, init: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
...(init.headers as any)
}
const csrf = getCookie('csrf_token')
if (csrf) headers['X-CSRF-Token'] = csrf
const attempt = async (): Promise<T> => {
const headers: Record<string, string> = {
Accept: 'application/json',
...(init.headers as any)
}
const csrf = getCookie('csrf_token')
if (csrf) headers['X-CSRF-Token'] = csrf
const resp = await fetch(url, {
credentials: 'include',
...init,
headers
})
const data = await resp.json().catch(() => ({}))
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? data.response : data
if (!resp.ok) {
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
throw new Error(msg)
const resp = await fetch(url, {
credentials: 'include',
...init,
headers
})
const data = await resp.json().catch(() => ({}))
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
if (!resp.ok) {
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
throw new Error(msg)
}
return unwrapped as T
}
// Try once; on transient failure for GET, retry quickly once
try {
return await attempt()
} catch (e) {
const method = (init.method || 'GET').toString().toUpperCase()
if (method === 'GET') {
await new Promise(r => setTimeout(r, 200))
return await attempt()
}
throw e
}
return unwrapped as T
}