Repository: vitalik/django-ninja
Pull request #1257 (2024-08-06 21:46:46)

Distinguish between AuthenticationError and AuthorizationError
Implements #1218

lordi: As far as I'm concerned, this looks good, thx
BlaiseMuhirwa: Can we merge this soon? I also think it looks good and I need this distinction as well since I currently use `django-ninja`. 
vitalik: I think this might be done better...


`AuthenticationError`, `AuthorizationError` should  be both inherited from HttpError (with corresponding codes and messages)

that way only one default http-error handler is needed (and users should be able to overwrite custom handlers for each class individualy)


Patch:
diff --git a/docs/docs/guides/errors.md b/docs/docs/guides/errors.md
index de3b6ea..7d7c617 100644
--- a/docs/docs/guides/errors.md
+++ b/docs/docs/guides/errors.md
@@ -59,6 +59,10 @@ By default, **Django Ninja** initialized the following exception handlers:
 
 Raised when authentication data is not valid
 
+#### `ninja.errors.AuthorizationError`
+
+Raised when authentication data is valid, but doesn't allow you to access the resource
+
 #### `ninja.errors.ValidationError`
 
 Raised when request data does not validate
diff --git a/docs/docs/guides/response/index.md b/docs/docs/guides/response/index.md
index 838a44b..94c9b84 100644
--- a/docs/docs/guides/response/index.md
+++ b/docs/docs/guides/response/index.md
@@ -262,6 +262,7 @@ In case of authentication, for example, you can return:
 - **200** successful -> token
 - **401** -> Unauthorized
 - **402** -> Payment required
+- **403** -> Forbidden
 - etc..
 
 In fact, the [OpenAPI specification](https://swagger.io/docs/specification/describing-responses/) allows you to pass multiple response schemas.
diff --git a/ninja/errors.py b/ninja/errors.py
index 95a1300..f186519 100644
--- a/ninja/errors.py
+++ b/ninja/errors.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
 __all__ = [
     "ConfigError",
     "AuthenticationError",
+    "AuthorizationError",
     "ValidationError",
     "HttpError",
     "set_default_exc_handlers",
@@ -27,10 +28,6 @@ class ConfigError(Exception):
     pass
 
 
-class AuthenticationError(Exception):
-    pass
-
-
 class ValidationError(Exception):
     """
     This exception raised when operation params do not validate
@@ -53,6 +50,16 @@ class HttpError(Exception):
         return self.message
 
 
+class AuthenticationError(HttpError):
+    def __init__(self, status_code: int = 401, message: str = "Unauthorized") -> None:
+        super().__init__(status_code=status_code, message=message)
+
+
+class AuthorizationError(HttpError):
+    def __init__(self, status_code: int = 403, message: str = "Forbidden") -> None:
+        super().__init__(status_code=status_code, message=message)
+
+
 class Throttled(HttpError):
     def __init__(self, wait: Optional[int]) -> None:
         self.wait = wait
@@ -76,10 +83,6 @@ def set_default_exc_handlers(api: "NinjaAPI") -> None:
         ValidationError,
         partial(_default_validation_error, api=api),
     )
-    api.add_exception_handler(
-        AuthenticationError,
-        partial(_default_authentication_error, api=api),
-    )
 
 
 def _default_404(request: HttpRequest, exc: Exception, api: "NinjaAPI") -> HttpResponse:
@@ -101,12 +104,6 @@ def _default_validation_error(
     return api.create_response(request, {"detail": exc.errors}, status=422)
 
 
-def _default_authentication_error(
-    request: HttpRequest, exc: AuthenticationError, api: "NinjaAPI"
-) -> HttpResponse:
-    return api.create_response(request, {"detail": "Unauthorized"}, status=401)
-
-
 def _default_exception(
     request: HttpRequest, exc: Exception, api: "NinjaAPI"
 ) -> HttpResponse:


Test patch:
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 01121bd..01fb648 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -3,7 +3,7 @@ from unittest.mock import Mock
 import pytest
 
 from ninja import NinjaAPI
-from ninja.errors import ConfigError
+from ninja.errors import AuthorizationError, ConfigError
 from ninja.security import (
     APIKeyCookie,
     APIKeyHeader,
@@ -60,6 +60,8 @@ class BearerAuth(HttpBearer):
     def authenticate(self, request, token):
         if token == "bearertoken":
             return token
+        if token == "nottherightone":
+            raise AuthorizationError
 
 
 def demo_operation(request):
@@ -102,6 +104,7 @@ class MockSuperUser(str):
 
 
 BODY_UNAUTHORIZED_DEFAULT = dict(detail="Unauthorized")
+BODY_FORBIDDEN_DEFAULT = dict(detail="Forbidden")
 
 
 @pytest.mark.parametrize(
@@ -178,6 +181,18 @@ BODY_UNAUTHORIZED_DEFAULT = dict(detail="Unauthorized")
             401,
             BODY_UNAUTHORIZED_DEFAULT,
         ),
+        (
+            "/bearer",
+            dict(headers={"Authorization": "Bearer nonexistingtoken"}),
+            401,
+            BODY_UNAUTHORIZED_DEFAULT,
+        ),
+        (
+            "/bearer",
+            dict(headers={"Authorization": "Bearer nottherightone"}),
+            403,
+            BODY_FORBIDDEN_DEFAULT,
+        ),
         ("/customexception", {}, 401, dict(custom=True)),
         (
             "/customexception",