Repository: vitalik/django-ninja

Issue:
Router does not support auth inheritance
**Describe the bug**
When you try adding a new router to an existing router, the leaf router doesn't inherit the top-level auth.
Consider the below example:

```py
from ninja import NinjaAPI, Router
from ninja.security import APIKeyQuery

api = NinjaAPI()

r1 = Router()
r2 = Router()
r3 = Router()


class Auth(APIKeyQuery):
    def __init__(self, secret):
        self.secret = secret
        super().__init__()

    def authenticate(self, request, key):
        if key == self.secret:
            return key


api.add_router("/r1", r1, auth=Auth("r1_auth"))
r1.add_router("/r2", r2)
r2.add_router("/r3", r3)


@r1.get("/")
def op1(request):
    return request.auth


@r2.get("/")
def op2(request):
    return request.auth


@r3.get("/")
def op3(request):
    return request.auth

```

So the auth provided for router `r1` won't be present for any operations in routers `r2` and `r3` even though it comes under it.
This is only for routers though. If we add auth when we initialize `NinjaApi()`  it propagates down to all routers and endpoints even if we provide the auth when initializing router r1 as `r1 = Router(auth=Auth("r1_auth"))`. Screenshot of the above code is shown below.

<img width="1453" alt="Screen Shot 2023-01-28 at 9 44 00 AM" src="https://user-images.githubusercontent.com/38973423/215273256-f2b43a14-153a-4e5b-9111-2aa779fc6a0c.png">

I think it's super helpful to include this in the documentation if we don't plan to support it as a dev can easily misinterpret it and in turn, pose a security threat to the app they are building.


**Versions (please complete the following information):**
 - Python version: 3.9.12
 - Django version: 4.1.5
 - Django-Ninja version: 0.20.0
 - Pydantic version: 1.10.4



Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d9e7ff5..5374fc6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,7 @@ repos:
         additional_dependencies: ["django-stubs", "pydantic"]
         exclude: (tests|docs)/
   - repo: https://github.com/psf/black
-    rev: "22.3.0"
+    rev: "23.1.0"
     hooks:
       - id: black
         exclude: docs/src/
diff --git a/ninja/main.py b/ninja/main.py
index fe70c1f..15ce90b 100644
--- a/ninja/main.py
+++ b/ninja/main.py
@@ -353,7 +353,7 @@ class NinjaAPI:
             prefix = normalize_path("/".join((parent_prefix, prefix))).lstrip("/")
 
         self._routers.extend(router.build_routers(prefix))
-        router.set_api_instance(self)
+        router.set_api_instance(self, parent_router)
 
     @property
     def urls(self) -> Tuple[List[Union[URLResolver, URLPattern]], str, str]:
diff --git a/ninja/openapi/schema.py b/ninja/openapi/schema.py
index 022b51d..ce17241 100644
--- a/ninja/openapi/schema.py
+++ b/ninja/openapi/schema.py
@@ -179,7 +179,6 @@ class OpenAPISchema(dict):
         by_alias: bool = True,
         remove_level: bool = True,
     ) -> Tuple[DictStrAny, bool]:
-
         if hasattr(model, "_flatten_map"):
             schema = self._flatten_schema(model)
         else:
@@ -242,7 +241,6 @@ class OpenAPISchema(dict):
 
         result = {}
         for status, model in operation.response_models.items():
-
             if status == Ellipsis:
                 continue  # it's not yet clear what it means if user wants to output any other code
 
diff --git a/ninja/params_models.py b/ninja/params_models.py
index 3c928fc..3714a87 100644
--- a/ninja/params_models.py
+++ b/ninja/params_models.py
@@ -163,7 +163,6 @@ class FileModel(ParamModel):
 
 
 class _HttpRequest(HttpRequest):
-
     body: bytes = b""
 
 
diff --git a/ninja/router.py b/ninja/router.py
index 2bfaa11..53bdec3 100644
--- a/ninja/router.py
+++ b/ninja/router.py
@@ -299,6 +299,8 @@ class Router:
         self, api: "NinjaAPI", parent_router: Optional["Router"] = None
     ) -> None:
         # TODO: check - parent_router seems not used
+        if self.auth is NOT_SET and parent_router and parent_router.auth:
+            self.auth = parent_router.auth
         self.api = api
         for path_view in self.path_operations.values():
             path_view.set_api_instance(self.api, self)
diff --git a/ninja/security/http.py b/ninja/security/http.py
index 8deb1c4..6a67296 100644
--- a/ninja/security/http.py
+++ b/ninja/security/http.py
@@ -83,5 +83,7 @@ class HttpBasicAuth(HttpAuthBase, ABC):  # TODO: maybe HttpBasicAuthBase
         try:
             username, password = b64decode(user_pass_encoded).decode().split(":", 1)
             return unquote(username), unquote(password)
-        except Exception as e:  # dear contributors please do not change to valueerror - here can be multiple exceptions
+        except (
+            Exception
+        ) as e:  # dear contributors please do not change to valueerror - here can be multiple exceptions
             raise DecodeError("Invalid Authorization header") from e


Test patch:
diff --git a/tests/test_auth_inheritance_routers.py b/tests/test_auth_inheritance_routers.py
new file mode 100644
index 0000000..7457026
--- /dev/null
+++ b/tests/test_auth_inheritance_routers.py
@@ -0,0 +1,76 @@
+import pytest
+
+from ninja import NinjaAPI, Router
+from ninja.security import APIKeyQuery
+from ninja.testing import TestClient
+
+
+class Auth(APIKeyQuery):
+    def __init__(self, secret):
+        self.secret = secret
+        super().__init__()
+
+    def authenticate(self, request, key):
+        if key == self.secret:
+            return key
+
+
+api = NinjaAPI()
+
+r1 = Router()
+r2 = Router()
+r3 = Router()
+r4 = Router()
+
+api.add_router("/r1", r1, auth=Auth("r1_auth"))
+r1.add_router("/r2", r2)
+r2.add_router("/r3", r3)
+r3.add_router("/r4", r4, auth=Auth("r4_auth"))
+
+client = TestClient(api)
+
+
+@r1.get("/")
+def op1(request):
+    return request.auth
+
+
+@r2.get("/")
+def op2(request):
+    return request.auth
+
+
+@r3.get("/")
+def op3(request):
+    return request.auth
+
+
+@r4.get("/")
+def op4(request):
+    return request.auth
+
+
+@r3.get("/op5", auth=Auth("op5_auth"))
+def op5(request):
+    return request.auth
+
+
+@pytest.mark.parametrize(
+    "route, status_code",
+    [
+        ("/r1/", 401),
+        ("/r1/r2/", 401),
+        ("/r1/r2/r3/", 401),
+        ("/r1/r2/r3/r4/", 401),
+        ("/r1/r2/r3/op5", 401),
+        ("/r1/?key=r1_auth", 200),
+        ("/r1/r2/?key=r1_auth", 200),
+        ("/r1/r2/r3/?key=r1_auth", 200),
+        ("/r1/r2/r3/r4/?key=r4_auth", 200),
+        ("/r1/r2/r3/op5?key=op5_auth", 200),
+        ("/r1/r2/r3/r4/?key=r1_auth", 401),
+        ("/r1/r2/r3/op5?key=r1_auth", 401),
+    ],
+)
+def test_router_inheritance_auth(route, status_code):
+    assert client.get(route).status_code == status_code
diff --git a/tests/test_docs/test_body.py b/tests/test_docs/test_body.py
index c259424..a83ca3c 100644
--- a/tests/test_docs/test_body.py
+++ b/tests/test_docs/test_body.py
@@ -5,7 +5,6 @@ from ninja.testing import TestClient
 
 
 def test_examples():
-
     api = NinjaAPI()
 
     with patch("builtins.api", api, create=True):
diff --git a/tests/test_docs/test_form.py b/tests/test_docs/test_form.py
index 0bc3597..315e0f2 100644
--- a/tests/test_docs/test_form.py
+++ b/tests/test_docs/test_form.py
@@ -5,7 +5,6 @@ from ninja.testing import TestClient
 
 
 def test_examples():
-
     api = NinjaAPI()
 
     with patch("builtins.api", api, create=True):
diff --git a/tests/test_docs/test_path.py b/tests/test_docs/test_path.py
index fbd7cb1..1adba45 100644
--- a/tests/test_docs/test_path.py
+++ b/tests/test_docs/test_path.py
@@ -5,7 +5,6 @@ from ninja.testing import TestClient
 
 
 def test_examples():
-
     api = NinjaAPI()
 
     with patch("builtins.api", api, create=True):
diff --git a/tests/test_docs/test_query.py b/tests/test_docs/test_query.py
index a4df178..a94b022 100644
--- a/tests/test_docs/test_query.py
+++ b/tests/test_docs/test_query.py
@@ -5,7 +5,6 @@ from ninja.testing import TestClient
 
 
 def test_examples():
-
     api = NinjaAPI()
 
     with patch("builtins.api", api, create=True):
diff --git a/tests/test_export_openapi_schema.py b/tests/test_export_openapi_schema.py
index bf9fd96..0555e79 100644
--- a/tests/test_export_openapi_schema.py
+++ b/tests/test_export_openapi_schema.py
@@ -11,7 +11,6 @@ from ninja.management.commands.export_openapi_schema import Command as ExportCmd
 
 
 def test_export_default():
-
     output = StringIO()
     call_command(ExportCmd(), stdout=output)
     json.loads(output.getvalue())  # if no exception, then OK
diff --git a/tests/test_openapi_schema.py b/tests/test_openapi_schema.py
index b5507be..f0e6ab7 100644
--- a/tests/test_openapi_schema.py
+++ b/tests/test_openapi_schema.py
@@ -660,7 +660,6 @@ def test_union_payload_simple(schema):
 
 
 def test_get_openapi_urls():
-
     api = NinjaAPI(openapi_url=None)
     paths = get_openapi_urls(api)
     assert len(paths) == 0
@@ -677,7 +676,6 @@ def test_get_openapi_urls():
 
 
 def test_unique_operation_ids():
-
     api = NinjaAPI()
 
     @api.get("/1")
diff --git a/tests/test_test_client.py b/tests/test_test_client.py
index c0114f9..f95080e 100644
--- a/tests/test_test_client.py
+++ b/tests/test_test_client.py
@@ -67,7 +67,6 @@ def test_django_2_2_plus_headers(version, has_headers):
 
 
 class ClientTestSchema(Schema):
-
     time: datetime