Close /api/health details and API docs to the public

Health: public gets only ok/error; details need X-Internal-Key. Docs/schema: admin session only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:05:19 +03:30
parent d6529956e1
commit 22c3196343
9 changed files with 74 additions and 53 deletions

View File

@@ -190,6 +190,9 @@ def _warnings() -> list[str]:
return out
from payment.permissions import HasInternalServiceKey # noqa: E402
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
@@ -213,6 +216,13 @@ class HealthView(APIView):
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
status = 200 if critical_ok else 503
# عموم فقط ok/error می‌بینند؛ جزئیات (هشدارها، ترافیک، سرویس‌های وصل‌شده، IP) پشتِ هدرِ
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی می‌ماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
# در .env نباشد، هیچ‌کس جزئیات را نمی‌بیند (نه اینکه همه ببینند).
if not HasInternalServiceKey().has_permission(request, self):
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
warnings = _warnings()
payload = {
'status': 'ok' if critical_ok else 'error',
@@ -228,4 +238,4 @@ class HealthView(APIView):
# را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد.
'clientIp': ScopedRateThrottle().get_ident(request),
}
return Response(payload, status=200 if critical_ok else 503)
return Response(payload, status=status)

View File

@@ -636,6 +636,11 @@ SPECTACULAR_SETTINGS = {
{'name': 'System', 'description': 'سلامتِ سرویس'},
],
'SERVE_INCLUDE_SCHEMA': False,
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser به‌تنهایی کافی نیست: احرازِ پیش‌فرضِ DRF
# فقط Bearer است و ادمینِ پنل ۴۰۱ می‌گرفت؛ SessionAuthentication کوکیِ پنل را می‌شناسد.
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بی‌نیاز از HTTP).
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
'COMPONENT_SPLIT_REQUEST': True,
# دو فیلدِ «kind» با دو مجموعه‌ی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» می‌نامید و هشدار می‌داد.

View File

@@ -467,6 +467,15 @@ class ApiDocsRtlShellTests(TestCase):
دیگری نمی‌گیردش.
"""
def setUp(self):
from django.contrib.auth.models import User as AuthUser
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
def test_docs_are_closed_to_anonymous_visitors(self):
from rest_framework.test import APIClient
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
def test_swagger_ui_carries_the_rtl_shell(self):
html = self.client.get('/api/docs/').content.decode()
self.assertIn('direction: rtl', html)
@@ -490,3 +499,20 @@ class InsecureSecretKeyWarningTests(TestCase):
self.assertIn('django-insecure', ' '.join(health._warnings()))
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
class HealthIsMinimalForThePublicTests(TestCase):
"""/api/health بی‌کلید فقط ok/error می‌دهد؛ با کلیدِ درست جزئیات."""
@override_settings(INTERNAL_SERVICE_KEY='k')
def test_public_sees_only_status(self):
body = self.client.get('/api/health').json()
self.assertEqual(set(body), {'status'})
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
self.assertEqual(set(wrong), {'status'})
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
self.assertIn('traffic', full)
@override_settings(INTERNAL_SERVICE_KEY='')
def test_without_a_configured_key_nobody_gets_details(self):
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})

View File

@@ -36,6 +36,7 @@ class _Quiet:
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
@override_settings(INTERNAL_SERVICE_KEY='k')
class RedisDownTests(_Quiet, TestCase):
"""پورتِ ۱ بسته است: هر اتصال فوراً رد می‌شود."""
@@ -86,7 +87,7 @@ class RedisDownTests(_Quiet, TestCase):
self.assertNotEqual(response.status_code, 500)
def test_health_says_so(self):
response = APIClient().get('/api/health')
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertEqual(response.status_code, 503)
self.assertIs(response.json()['checks']['cache']['ok'], False)
@@ -107,6 +108,7 @@ def _free_port():
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
@override_settings(INTERNAL_SERVICE_KEY='k')
class RealRedisTests(_Quiet, TestCase):
def setUp(self):
super().setUp()
@@ -155,7 +157,7 @@ class RealRedisTests(_Quiet, TestCase):
def test_throttle_is_shared_and_health_is_ok(self):
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
response = APIClient().get('/api/health')
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
self.assertIs(response.json()['checks']['cache']['ok'], True)
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
@@ -168,9 +170,9 @@ class RealRedisTests(_Quiet, TestCase):
with self.assertLogs('config.cache', 'ERROR'):
self.assertEqual(c.get('k', 'miss'), 'miss')
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
self.assertEqual(APIClient().get('/api/health').status_code, 503)
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 503)
self.start() # برمی‌گردد، بدونِ ری‌استارتِ برنامه
c.set('k2', 'after', 60)
self.assertEqual(c.get('k2'), 'after')
self.assertEqual(APIClient().get('/api/health').status_code, 200)
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)